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
@@ -0,0 +1,595 @@
1
+ import { Component, Input, Output, EventEmitter, OnInit, AfterViewInit, OnDestroy, ViewChild, ElementRef, NgZone, OnChanges, SimpleChanges } from '@angular/core';
2
+ import { TimeMode, TimePickerLabels } from '../../models/time-picker.models';
3
+
4
+ type WheelType = 'hour' | 'minute' | 'period';
5
+
6
+ @Component({
7
+ selector: 'lib-time-wheel-panel',
8
+ templateUrl: './time-wheel-panel.component.html',
9
+ styleUrls: ['./time-wheel-panel.component.scss'],
10
+ standalone: false
11
+ })
12
+ export class TimeWheelPanelComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
13
+ @Input() mode: TimeMode = '12';
14
+ @Input() value: string = '';
15
+ @Input() labels?: TimePickerLabels;
16
+ @Input() minuteStep: number = 0;
17
+ @Input() minTime?: string; // "HH:mm" 24h
18
+ @Input() maxTime?: string; // "HH:mm" 24h
19
+
20
+ @Output() confirmed = new EventEmitter<string>();
21
+ @Output() cancelled = new EventEmitter<void>();
22
+
23
+ @ViewChild('hourWheel') hourWheel!: ElementRef<HTMLDivElement>;
24
+ @ViewChild('minuteWheel') minuteWheel!: ElementRef<HTMLDivElement>;
25
+ @ViewChild('periodWheel') periodWheel?: ElementRef<HTMLDivElement>;
26
+
27
+ // ─── Geometry (single source of truth; keep in sync with SCSS) ───
28
+ static readonly ITEM_HEIGHT = 36;
29
+ static readonly VISIBLE_ROWS = 3;
30
+ readonly ITEM_HEIGHT = TimeWheelPanelComponent.ITEM_HEIGHT;
31
+ readonly SPACERS = (TimeWheelPanelComponent.VISIBLE_ROWS - 1) / 2; // leading == trailing
32
+ readonly spacerRows: number[] = Array.from({ length: this.SPACERS }, (_, i) => i);
33
+ private get containerHeight(): number {
34
+ return TimeWheelPanelComponent.VISIBLE_ROWS * this.ITEM_HEIGHT;
35
+ }
36
+
37
+ hours: string[] = [];
38
+ minutes: string[] = [];
39
+ periods: string[] = ['AM', 'PM'];
40
+
41
+ // How many times each list is repeated in the DOM for the infinite loop.
42
+ // Computed per-list so one repeat band is always >= the viewport height.
43
+ hourRepeats: number[] = [0, 1, 2];
44
+ minuteRepeats: number[] = [0, 1, 2];
45
+
46
+ selectedHour: string = '09';
47
+ selectedMinute: string = '00';
48
+ selectedPeriod: string = 'AM';
49
+
50
+ disabledHours = new Set<string>();
51
+ disabledMinutes = new Set<string>();
52
+ disabledPeriods = new Set<string>();
53
+
54
+ private isInitialScrollDone: boolean = false;
55
+
56
+ private scrollListeners: Partial<Record<WheelType, () => void>> = {};
57
+ private idleTimers: Partial<Record<WheelType, ReturnType<typeof setTimeout>>> = {};
58
+ private rafIds: Partial<Record<WheelType, number>> = {};
59
+
60
+ private static readonly IDLE_MS = 90;
61
+
62
+ constructor(private zone: NgZone) {}
63
+
64
+ ngOnInit(): void {
65
+ this.generateHours();
66
+ this.generateMinutes();
67
+ this.parseInitialValue(this.value);
68
+ this.computeConstraints();
69
+ }
70
+
71
+ ngOnChanges(changes: SimpleChanges): void {
72
+ if (changes['mode'] || changes['minuteStep'] || changes['minTime'] || changes['maxTime']) {
73
+ this.generateHours();
74
+ this.generateMinutes();
75
+ this.computeConstraints();
76
+ if (this.isInitialScrollDone) {
77
+ this.zone.runOutsideAngular(() => {
78
+ this.scrollToValue('hour', this.selectedHour, false);
79
+ this.scrollToValue('minute', this.selectedMinute, false);
80
+ if (this.mode === '12') {
81
+ this.scrollToValue('period', this.selectedPeriod, false);
82
+ }
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ ngAfterViewInit(): void {
89
+ setTimeout(() => {
90
+ this.scrollToValue('hour', this.selectedHour, false);
91
+ this.scrollToValue('minute', this.selectedMinute, false);
92
+ if (this.mode === '12') {
93
+ this.scrollToValue('period', this.selectedPeriod, false);
94
+ }
95
+
96
+ this.updateWheelEffects(this.hourWheel.nativeElement);
97
+ this.updateWheelEffects(this.minuteWheel.nativeElement);
98
+ if (this.periodWheel) {
99
+ this.updateWheelEffects(this.periodWheel.nativeElement);
100
+ }
101
+
102
+ this.isInitialScrollDone = true;
103
+ }, 150);
104
+
105
+ // Native scroll drives everything. On each scroll event we (rAF-throttled)
106
+ // repaint the 3D wheel effect, and after the wheel goes idle we read the
107
+ // settled value + do the seamless wrap-around reposition.
108
+ this.zone.runOutsideAngular(() => {
109
+ this.attachScroll('hour', this.hourWheel.nativeElement);
110
+ this.attachScroll('minute', this.minuteWheel.nativeElement);
111
+ if (this.periodWheel) {
112
+ this.attachScroll('period', this.periodWheel.nativeElement);
113
+ }
114
+ });
115
+ }
116
+
117
+ ngOnDestroy(): void {
118
+ const map: Partial<Record<WheelType, ElementRef<HTMLDivElement> | undefined>> = {
119
+ hour: this.hourWheel,
120
+ minute: this.minuteWheel,
121
+ period: this.periodWheel
122
+ };
123
+ (Object.keys(this.scrollListeners) as WheelType[]).forEach(type => {
124
+ const el = map[type]?.nativeElement;
125
+ const listener = this.scrollListeners[type];
126
+ if (el && listener) {
127
+ el.removeEventListener('scroll', listener);
128
+ }
129
+ const raf = this.rafIds[type];
130
+ if (raf) cancelAnimationFrame(raf);
131
+ const timer = this.idleTimers[type];
132
+ if (timer) clearTimeout(timer);
133
+ });
134
+ }
135
+
136
+ private attachScroll(type: WheelType, element: HTMLElement): void {
137
+ const listener = () => {
138
+ // Throttle the visual repaint to one per frame.
139
+ if (!this.rafIds[type]) {
140
+ this.rafIds[type] = requestAnimationFrame(() => {
141
+ this.rafIds[type] = undefined as unknown as number;
142
+ this.updateWheelEffects(element);
143
+ });
144
+ }
145
+
146
+ // Debounce the "settled" read.
147
+ const existing = this.idleTimers[type];
148
+ if (existing) clearTimeout(existing);
149
+ this.idleTimers[type] = setTimeout(() => {
150
+ this.onScrollIdle(type, element);
151
+ }, TimeWheelPanelComponent.IDLE_MS);
152
+ };
153
+
154
+ this.scrollListeners[type] = listener;
155
+ element.addEventListener('scroll', listener, { passive: true });
156
+ }
157
+
158
+ private generateHours(): void {
159
+ if (this.mode === '12') {
160
+ this.hours = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
161
+ } else {
162
+ this.hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
163
+ }
164
+ this.hourRepeats = this.buildRepeats(this.hours.length);
165
+ }
166
+
167
+ private generateMinutes(): void {
168
+ const step = Math.max(1, this.minuteStep);
169
+ this.minutes = [];
170
+ for (let i = 0; i < 60; i += step) {
171
+ this.minutes.push(i.toString().padStart(2, '0'));
172
+ }
173
+ this.minuteRepeats = this.buildRepeats(this.minutes.length);
174
+ }
175
+
176
+ // Odd repeat count so that one buffer band (N*ITEM_HEIGHT) on each side of the
177
+ // central band is always >= the viewport height. Common case (N=12/24/60) → 3.
178
+ private buildRepeats(n: number): number[] {
179
+ const band = n * this.ITEM_HEIGHT;
180
+ const count = Math.max(3, 2 * Math.ceil(this.containerHeight / band) + 1);
181
+ return Array.from({ length: count }, (_, i) => i);
182
+ }
183
+
184
+ private centralBandStart(list: string[], repeats: number[]): number {
185
+ // Index (in the repeated DOM list) of the first item of the central band.
186
+ return Math.floor(repeats.length / 2) * list.length;
187
+ }
188
+
189
+ private parseInitialValue(valStr: string): void {
190
+ if (!valStr) {
191
+ this.setDefaultTime();
192
+ return;
193
+ }
194
+
195
+ const twelveRegex = /^(\d{1,2}):(\d{2})\s*(AM|PM)$/i;
196
+ const twentyFourRegex = /^(\d{1,2}):(\d{2})$/;
197
+
198
+ let match = valStr.trim().match(twelveRegex);
199
+ if (match) {
200
+ const hh = match[1].padStart(2, '0');
201
+ const mm = match[2];
202
+ const period = match[3].toUpperCase();
203
+
204
+ if (this.mode === '12') {
205
+ this.selectedHour = hh;
206
+ this.selectedMinute = this.findClosestMinute(mm);
207
+ this.selectedPeriod = period;
208
+ } else {
209
+ // Convert to 24h
210
+ let hNum = parseInt(hh, 10);
211
+ if (period === 'PM' && hNum !== 12) hNum += 12;
212
+ if (period === 'AM' && hNum === 12) hNum = 0;
213
+ this.selectedHour = hNum.toString().padStart(2, '0');
214
+ this.selectedMinute = this.findClosestMinute(mm);
215
+ }
216
+ return;
217
+ }
218
+
219
+ match = valStr.trim().match(twentyFourRegex);
220
+ if (match) {
221
+ const hh = match[1].padStart(2, '0');
222
+ const mm = match[2];
223
+
224
+ if (this.mode === '24') {
225
+ this.selectedHour = hh;
226
+ this.selectedMinute = this.findClosestMinute(mm);
227
+ } else {
228
+ // Convert to 12h
229
+ let hNum = parseInt(hh, 10);
230
+ const period = hNum >= 12 ? 'PM' : 'AM';
231
+ if (hNum > 12) hNum -= 12;
232
+ if (hNum === 0) hNum = 12;
233
+ this.selectedHour = hNum.toString().padStart(2, '0');
234
+ this.selectedMinute = this.findClosestMinute(mm);
235
+ this.selectedPeriod = period;
236
+ }
237
+ return;
238
+ }
239
+
240
+ this.setDefaultTime();
241
+ }
242
+
243
+ private findClosestMinute(mmStr: string): string {
244
+ const mmVal = parseInt(mmStr, 10);
245
+ if (isNaN(mmVal)) return '00';
246
+ let closest = this.minutes[0];
247
+ let minDiff = 60;
248
+ for (const m of this.minutes) {
249
+ const mVal = parseInt(m, 10);
250
+ const diff = Math.abs(mVal - mmVal);
251
+ if (diff < minDiff) {
252
+ minDiff = diff;
253
+ closest = m;
254
+ }
255
+ }
256
+ return closest;
257
+ }
258
+
259
+ private setDefaultTime(): void {
260
+ if (this.mode === '12') {
261
+ this.selectedHour = '09';
262
+ this.selectedMinute = '00';
263
+ this.selectedPeriod = 'AM';
264
+ } else {
265
+ this.selectedHour = '09';
266
+ this.selectedMinute = '00';
267
+ }
268
+ }
269
+
270
+ private toMinutes(h: number, m: number): number {
271
+ return h * 60 + m;
272
+ }
273
+
274
+ private parseLimit(hhmm: string): { h: number; m: number } {
275
+ const parts = hhmm.split(':');
276
+ return {
277
+ h: parseInt(parts[0], 10) || 0,
278
+ m: parseInt(parts[1], 10) || 0
279
+ };
280
+ }
281
+
282
+ private to24hrHour(displayHour: string, period: 'AM' | 'PM'): number {
283
+ const h = parseInt(displayHour, 10);
284
+ if (period === 'AM') {
285
+ return h === 12 ? 0 : h;
286
+ } else {
287
+ return h === 12 ? 12 : h + 12;
288
+ }
289
+ }
290
+
291
+ computeConstraints(): void {
292
+ this.disabledHours.clear();
293
+ this.disabledMinutes.clear();
294
+ this.disabledPeriods.clear();
295
+
296
+ if (!this.minTime && !this.maxTime) return;
297
+
298
+ let minMin = 0;
299
+ let maxMin = 23 * 60 + 59;
300
+
301
+ if (this.minTime) {
302
+ const parsed = this.parseLimit(this.minTime);
303
+ minMin = this.toMinutes(parsed.h, parsed.m);
304
+ }
305
+ if (this.maxTime) {
306
+ const parsed = this.parseLimit(this.maxTime);
307
+ maxMin = this.toMinutes(parsed.h, parsed.m);
308
+ }
309
+
310
+ if (minMin > maxMin) {
311
+ // Invalid config
312
+ return;
313
+ }
314
+
315
+ if (this.mode === '24') {
316
+ for (const h of this.hours) {
317
+ const hNum = parseInt(h, 10);
318
+ const hourStart = this.toMinutes(hNum, 0);
319
+ const hourEnd = this.toMinutes(hNum, 59);
320
+ if (hourEnd < minMin || hourStart > maxMin) {
321
+ this.disabledHours.add(h);
322
+ }
323
+ }
324
+
325
+ const selH = parseInt(this.selectedHour, 10);
326
+ for (const m of this.minutes) {
327
+ const mNum = parseInt(m, 10);
328
+ const total = this.toMinutes(selH, mNum);
329
+ if (total < minMin || total > maxMin) {
330
+ this.disabledMinutes.add(m);
331
+ }
332
+ }
333
+ return;
334
+ }
335
+
336
+ // 12-hour mode logic
337
+ const amCoversAny = minMin < 12 * 60;
338
+ const pmCoversAny = maxMin >= 12 * 60;
339
+
340
+ if (!amCoversAny) this.disabledPeriods.add('AM');
341
+ if (!pmCoversAny) this.disabledPeriods.add('PM');
342
+
343
+ for (const h of this.hours) {
344
+ const hNum = parseInt(h, 10);
345
+
346
+ const h24_AM = hNum === 12 ? 0 : hNum;
347
+ const amStart = this.toMinutes(h24_AM, 0);
348
+ const amEnd = this.toMinutes(h24_AM, 59);
349
+ const amValid = amEnd >= minMin && amStart <= maxMin && amCoversAny;
350
+
351
+ const h24_PM = hNum === 12 ? 12 : hNum + 12;
352
+ const pmStart = this.toMinutes(h24_PM, 0);
353
+ const pmEnd = this.toMinutes(h24_PM, 59);
354
+ const pmValid = pmEnd >= minMin && pmStart <= maxMin && pmCoversAny;
355
+
356
+ if (this.selectedPeriod === 'AM' && !amValid) this.disabledHours.add(h);
357
+ if (this.selectedPeriod === 'PM' && !pmValid) this.disabledHours.add(h);
358
+ }
359
+
360
+ const selH24 = this.to24hrHour(this.selectedHour, this.selectedPeriod as 'AM' | 'PM');
361
+ for (const m of this.minutes) {
362
+ const mNum = parseInt(m, 10);
363
+ const total = this.toMinutes(selH24, mNum);
364
+ if (total < minMin || total > maxMin) {
365
+ this.disabledMinutes.add(m);
366
+ }
367
+ }
368
+ }
369
+
370
+ updateWheelEffects(element: HTMLElement): void {
371
+ const scrollTop = element.scrollTop;
372
+ const items = element.children;
373
+
374
+ for (let i = 0; i < items.length; i++) {
375
+ const item = items[i] as HTMLElement;
376
+ if (item.classList.contains('cc-twp-spacer')) continue;
377
+
378
+ const itemTop = (i - this.SPACERS) * this.ITEM_HEIGHT;
379
+ const distance = Math.abs(itemTop - scrollTop);
380
+
381
+ const maxDist = this.ITEM_HEIGHT * 2;
382
+ const ratio = Math.max(0, 1 - distance / maxDist);
383
+
384
+ // Flatter than the old drum look: gentle scale + opacity fade only.
385
+ const scale = 0.85 + 0.15 * ratio;
386
+ const opacity = 0.4 + 0.6 * ratio;
387
+
388
+ item.style.transform = `scale(${scale}) translateZ(0)`;
389
+ item.style.opacity = String(opacity);
390
+ }
391
+ }
392
+
393
+ private listFor(type: WheelType): string[] {
394
+ if (type === 'hour') return this.hours;
395
+ if (type === 'minute') return this.minutes;
396
+ return this.periods;
397
+ }
398
+
399
+ private repeatsFor(type: WheelType): number[] {
400
+ if (type === 'hour') return this.hourRepeats;
401
+ if (type === 'minute') return this.minuteRepeats;
402
+ return [0];
403
+ }
404
+
405
+ private isDisabled(type: WheelType, val: string): boolean {
406
+ if (type === 'hour') return this.disabledHours.has(val);
407
+ if (type === 'minute') return this.disabledMinutes.has(val);
408
+ return this.disabledPeriods.has(val);
409
+ }
410
+
411
+ // Called after native inertial scrolling settles.
412
+ private onScrollIdle(type: WheelType, element: HTMLElement): void {
413
+ if (!this.isInitialScrollDone) return;
414
+
415
+ const list = this.listFor(type);
416
+ const N = list.length;
417
+ const looping = type === 'hour' || type === 'minute';
418
+
419
+ // 1. Seamless wrap-around: keep the resting position inside the central band.
420
+ if (looping) {
421
+ const band = N * this.ITEM_HEIGHT;
422
+ const start = this.centralBandStart(list, this.repeatsFor(type)) * this.ITEM_HEIGHT;
423
+ const end = start + band;
424
+ if (element.scrollTop < start || element.scrollTop >= end) {
425
+ const originalScrollBehavior = element.style.scrollBehavior;
426
+ element.style.scrollBehavior = 'auto';
427
+
428
+ if (element.scrollTop < start) {
429
+ element.scrollTop += band;
430
+ } else {
431
+ element.scrollTop -= band;
432
+ }
433
+
434
+ // Force layout reflow
435
+ const _ = element.scrollTop;
436
+ element.style.scrollBehavior = originalScrollBehavior;
437
+ }
438
+ }
439
+
440
+ // 2. Read the settled value.
441
+ let index = Math.round(element.scrollTop / this.ITEM_HEIGHT);
442
+ let origIndex = looping ? ((index % N) + N) % N : Math.max(0, Math.min(N - 1, index));
443
+ let val = list[origIndex];
444
+
445
+ // 3. If it landed on a disabled value, snap to the nearest enabled one.
446
+ if (val && this.isDisabled(type, val)) {
447
+ const enabled = this.nearestEnabled(type, origIndex);
448
+ if (enabled != null) {
449
+ this.scrollToValue(type, list[enabled], true);
450
+ origIndex = enabled;
451
+ val = list[enabled];
452
+ }
453
+ } else {
454
+ // Enforce a clean rest position (native mandatory-snap usually already did).
455
+ const target = index * this.ITEM_HEIGHT;
456
+ if (Math.abs(element.scrollTop - target) > 0.5) {
457
+ this.scrollToValue(type, val, true);
458
+ }
459
+ }
460
+
461
+ if (!val) return;
462
+
463
+ // 4. Commit the selection inside Angular's zone.
464
+ if (type === 'hour' && this.selectedHour !== val) {
465
+ this.zone.run(() => {
466
+ this.selectedHour = val!;
467
+ this.computeConstraints();
468
+ this.adjustSelectionOnConstraintChange();
469
+ });
470
+ } else if (type === 'minute' && this.selectedMinute !== val) {
471
+ this.zone.run(() => {
472
+ this.selectedMinute = val!;
473
+ });
474
+ } else if (type === 'period' && this.selectedPeriod !== val) {
475
+ this.zone.run(() => {
476
+ this.selectedPeriod = val!;
477
+ this.computeConstraints();
478
+ this.adjustSelectionOnConstraintChange();
479
+ });
480
+ }
481
+ }
482
+
483
+ // Search outward from origIndex for the closest enabled value index.
484
+ private nearestEnabled(type: WheelType, origIndex: number): number | null {
485
+ const list = this.listFor(type);
486
+ const N = list.length;
487
+ const looping = type === 'hour' || type === 'minute';
488
+
489
+ if (!this.isDisabled(type, list[origIndex])) return origIndex;
490
+
491
+ for (let offset = 1; offset < N; offset++) {
492
+ const up = looping ? (origIndex + offset) % N : origIndex + offset;
493
+ if (up >= 0 && up < N && !this.isDisabled(type, list[up])) return up;
494
+
495
+ const down = looping ? ((origIndex - offset) % N + N) % N : origIndex - offset;
496
+ if (down >= 0 && down < N && !this.isDisabled(type, list[down])) return down;
497
+ }
498
+ return null;
499
+ }
500
+
501
+ private adjustSelectionOnConstraintChange(): void {
502
+ if (this.mode === '12') {
503
+ if (this.disabledPeriods.has(this.selectedPeriod)) {
504
+ const alt = this.periods.find(p => !this.disabledPeriods.has(p));
505
+ if (alt) {
506
+ this.selectedPeriod = alt;
507
+ this.scrollToValue('period', alt, true);
508
+ this.computeConstraints();
509
+ }
510
+ }
511
+
512
+ if (this.disabledHours.has(this.selectedHour)) {
513
+ const validHour = this.hours.find(h => !this.disabledHours.has(h));
514
+ if (validHour) {
515
+ this.selectedHour = validHour;
516
+ this.scrollToValue('hour', validHour, true);
517
+ this.computeConstraints();
518
+ }
519
+ }
520
+ } else {
521
+ if (this.disabledHours.has(this.selectedHour)) {
522
+ const validHour = this.hours.find(h => !this.disabledHours.has(h));
523
+ if (validHour) {
524
+ this.selectedHour = validHour;
525
+ this.scrollToValue('hour', validHour, true);
526
+ this.computeConstraints();
527
+ }
528
+ }
529
+ }
530
+
531
+ if (this.disabledMinutes.has(this.selectedMinute)) {
532
+ const validMinute = this.minutes.find(m => !this.disabledMinutes.has(m));
533
+ if (validMinute) {
534
+ this.selectedMinute = validMinute;
535
+ this.scrollToValue('minute', validMinute, true);
536
+ }
537
+ }
538
+ }
539
+
540
+ selectValue(type: WheelType, val: string): void {
541
+ if (type === 'hour') {
542
+ if (this.disabledHours.has(val)) return;
543
+ this.selectedHour = val;
544
+ } else if (type === 'minute') {
545
+ if (this.disabledMinutes.has(val)) return;
546
+ this.selectedMinute = val;
547
+ } else if (type === 'period') {
548
+ if (this.disabledPeriods.has(val)) return;
549
+ this.selectedPeriod = val;
550
+ }
551
+ this.scrollToValue(type, val, true);
552
+ this.computeConstraints();
553
+ this.adjustSelectionOnConstraintChange();
554
+ }
555
+
556
+ scrollToValue(type: WheelType, val: string, smooth: boolean): void {
557
+ let element: HTMLElement | null = null;
558
+ if (type === 'hour') element = this.hourWheel?.nativeElement ?? null;
559
+ else if (type === 'minute') element = this.minuteWheel?.nativeElement ?? null;
560
+ else element = this.periodWheel?.nativeElement ?? null;
561
+
562
+ if (!element) return;
563
+
564
+ const list = this.listFor(type);
565
+ const index = list.indexOf(val);
566
+ if (index === -1) return;
567
+
568
+ const looping = type === 'hour' || type === 'minute';
569
+ const base = looping ? this.centralBandStart(list, this.repeatsFor(type)) : 0;
570
+ const targetIndex = base + index;
571
+
572
+ const originalBehavior = element.style.scrollBehavior;
573
+ element.style.scrollBehavior = smooth ? 'smooth' : 'auto';
574
+ element.scrollTop = targetIndex * this.ITEM_HEIGHT;
575
+ // Force layout reflow
576
+ const _ = element.scrollTop;
577
+ element.style.scrollBehavior = originalBehavior;
578
+ }
579
+
580
+ onCancel(): void {
581
+ this.cancelled.emit();
582
+ }
583
+
584
+ onConfirm(): void {
585
+ this.confirmed.emit(this.getFormattedTime());
586
+ }
587
+
588
+ getFormattedTime(): string {
589
+ if (this.mode === '24') {
590
+ return `${this.selectedHour}:${this.selectedMinute}`;
591
+ } else {
592
+ return `${this.selectedHour}:${this.selectedMinute} ${this.selectedPeriod}`;
593
+ }
594
+ }
595
+ }
@@ -0,0 +1,49 @@
1
+ export type TimeMode = '12' | '24';
2
+ export type TimePickerVariant = 'default' | 'wheel';
3
+
4
+ export interface TimePickerLabels {
5
+ label?: string;
6
+ placeholder?: string;
7
+ hourLabel?: string; // "HOUR" column header — default 'Hour'
8
+ minuteLabel?: string; // "MINUTE" column header — default 'Minute'
9
+ periodLabel?: string; // "AM/PM" column header — default 'AM/PM'
10
+ amLabel?: string; // "AM" — default 'AM'
11
+ pmLabel?: string; // "PM" — default 'PM'
12
+ confirmLabel?: string; // Confirm button — default 'Confirm'
13
+ cancelLabel?: string; // Cancel button — default 'Cancel'
14
+ selectTimeTitle?: string; // Popup title — default 'Select Time'
15
+ requiredMarker?: string;
16
+ clearLabel?: string;
17
+ }
18
+
19
+ export interface TimePickerConfig {
20
+ label?: string;
21
+ placeholder?: string;
22
+ variant?: TimePickerVariant; // 'default' | 'wheel' — default 'wheel'
23
+ mode?: TimeMode; // '12' | '24' — default '12'
24
+ minuteStep?: number; // 0/1 (default) = every minute 00–59; 5, 10, 15, 30 add the break
25
+ disabled?: boolean;
26
+ required?: boolean;
27
+ errorMessage?: string;
28
+ // Style overrides (mirror datepicker pattern)
29
+ width?: string;
30
+ height?: string;
31
+ borderRadius?: string;
32
+ fontSize?: string;
33
+ gap?: string;
34
+ fontFamily?: string;
35
+ labelColor?: string;
36
+ labelFontSize?: string;
37
+ labelFontWeight?: string;
38
+ backgroundColor?: string;
39
+ borderColor?: string;
40
+ borderWidth?: string;
41
+ padding?: string;
42
+ fontWeight?: string;
43
+ color?: string;
44
+ placeholderColor?: string;
45
+ focusBorderColor?: string;
46
+ errorColor?: string;
47
+ disabledBackgroundColor?: string;
48
+ disabledColor?: string;
49
+ }
@@ -0,0 +1,23 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { TimePickerComponent } from './components/time-picker/time-picker.component';
5
+ import { TimeWheelPanelComponent } from './components/time-wheel-panel/time-wheel-panel.component';
6
+ import { FormComponentsModule } from '../form-components/form-components.module';
7
+
8
+ @NgModule({
9
+ declarations: [
10
+ TimePickerComponent,
11
+ TimeWheelPanelComponent
12
+ ],
13
+ imports: [
14
+ CommonModule,
15
+ FormsModule,
16
+ FormComponentsModule
17
+ ],
18
+ exports: [
19
+ TimePickerComponent,
20
+ TimeWheelPanelComponent
21
+ ]
22
+ })
23
+ export class TimePickerModule {}