@salas-ds/cli 0.1.0 → 0.2.1

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 (92) hide show
  1. package/dist/index.js +296 -94
  2. package/package.json +4 -5
  3. package/templates/angular/accordion/accordion-content.component.ts +9 -0
  4. package/templates/angular/accordion/accordion-item.component.ts +138 -0
  5. package/templates/angular/accordion/accordion-trigger.component.ts +9 -0
  6. package/templates/angular/accordion/accordion.component.ts +120 -0
  7. package/templates/angular/accordion/accordion.module.ts +21 -0
  8. package/templates/angular/autocomplete/autocomplete.component.ts +707 -0
  9. package/templates/angular/autocomplete/autocomplete.module.ts +8 -0
  10. package/templates/angular/avatar/avatar-badge.component.ts +18 -0
  11. package/templates/angular/avatar/avatar-fallback.component.ts +39 -0
  12. package/templates/angular/avatar/avatar-group-count.component.ts +46 -0
  13. package/templates/angular/avatar/avatar-group.component.ts +33 -0
  14. package/templates/angular/avatar/avatar-image.component.ts +57 -0
  15. package/templates/angular/avatar/avatar.component.ts +73 -0
  16. package/templates/angular/avatar/avatar.module.ts +27 -0
  17. package/templates/angular/badge/badge.component.ts +84 -0
  18. package/templates/angular/badge/badge.module.ts +9 -0
  19. package/templates/angular/button/button.component.ts +24 -4
  20. package/templates/angular/card/card.component.ts +100 -0
  21. package/templates/angular/card/card.module.ts +8 -0
  22. package/templates/angular/checkbox/checkbox.component.ts +172 -0
  23. package/templates/angular/checkbox/checkbox.module.ts +8 -0
  24. package/templates/angular/datepicker/datepicker.component.ts +660 -0
  25. package/templates/angular/datepicker/datepicker.module.ts +8 -0
  26. package/templates/angular/dialog/dialog-content.component.ts +9 -0
  27. package/templates/angular/dialog/dialog-description.component.ts +17 -0
  28. package/templates/angular/dialog/dialog-footer.component.ts +17 -0
  29. package/templates/angular/dialog/dialog-header.component.ts +14 -0
  30. package/templates/angular/dialog/dialog-title.component.ts +18 -0
  31. package/templates/angular/dialog/dialog-trigger.component.ts +9 -0
  32. package/templates/angular/dialog/dialog.component.ts +212 -0
  33. package/templates/angular/dialog/dialog.module.ts +31 -0
  34. package/templates/angular/input/input.component.ts +229 -0
  35. package/templates/angular/input/input.module.ts +8 -0
  36. package/templates/angular/scroll-area/scroll-area.component.ts +72 -0
  37. package/templates/angular/scroll-area/scroll-area.module.ts +9 -0
  38. package/templates/angular/scroll-area/scroll-bar.component.ts +15 -0
  39. package/templates/angular/select/select.component.ts +292 -0
  40. package/templates/angular/select/select.module.ts +8 -0
  41. package/templates/angular/separator/separator.component.ts +63 -0
  42. package/templates/angular/separator/separator.module.ts +9 -0
  43. package/templates/angular/sheet/sheet-content.component.ts +13 -0
  44. package/templates/angular/sheet/sheet-description.component.ts +29 -0
  45. package/templates/angular/sheet/sheet-footer.component.ts +27 -0
  46. package/templates/angular/sheet/sheet-header.component.ts +26 -0
  47. package/templates/angular/sheet/sheet-title.component.ts +31 -0
  48. package/templates/angular/sheet/sheet-trigger.component.ts +11 -0
  49. package/templates/angular/sheet/sheet.component.ts +251 -0
  50. package/templates/angular/sheet/sheet.module.ts +30 -0
  51. package/templates/angular/sidebar/sidebar-content.component.ts +25 -0
  52. package/templates/angular/sidebar/sidebar-footer.component.ts +20 -0
  53. package/templates/angular/sidebar/sidebar-group-content.component.ts +16 -0
  54. package/templates/angular/sidebar/sidebar-group-label.component.ts +20 -0
  55. package/templates/angular/sidebar/sidebar-group.component.ts +14 -0
  56. package/templates/angular/sidebar/sidebar-header.component.ts +25 -0
  57. package/templates/angular/sidebar/sidebar-inset.component.ts +85 -0
  58. package/templates/angular/sidebar/sidebar-menu-button.component.ts +75 -0
  59. package/templates/angular/sidebar/sidebar-menu-item.component.ts +14 -0
  60. package/templates/angular/sidebar/sidebar-menu.component.ts +19 -0
  61. package/templates/angular/sidebar/sidebar-provider.component.ts +77 -0
  62. package/templates/angular/sidebar/sidebar-trigger.component.ts +58 -0
  63. package/templates/angular/sidebar/sidebar.component.ts +228 -0
  64. package/templates/angular/sidebar/sidebar.module.ts +48 -0
  65. package/templates/angular/sidebar/sidebar.service.ts +93 -0
  66. package/templates/angular/skeleton/skeleton.component.ts +44 -0
  67. package/templates/angular/skeleton/skeleton.module.ts +8 -0
  68. package/templates/angular/spinner/spinner.component.ts +75 -0
  69. package/templates/angular/spinner/spinner.module.ts +8 -0
  70. package/templates/angular/table/table-body.component.ts +23 -0
  71. package/templates/angular/table/table-caption.component.ts +29 -0
  72. package/templates/angular/table/table-cell.component.ts +49 -0
  73. package/templates/angular/table/table-footer.component.ts +32 -0
  74. package/templates/angular/table/table-head.component.ts +48 -0
  75. package/templates/angular/table/table-header.component.ts +28 -0
  76. package/templates/angular/table/table-row.component.ts +36 -0
  77. package/templates/angular/table/table.component.ts +35 -0
  78. package/templates/angular/table/table.module.ts +33 -0
  79. package/templates/angular/tabs/tabs-content.component.ts +71 -0
  80. package/templates/angular/tabs/tabs-list.component.ts +70 -0
  81. package/templates/angular/tabs/tabs-trigger.component.ts +149 -0
  82. package/templates/angular/tabs/tabs.component.ts +155 -0
  83. package/templates/angular/tabs/tabs.module.ts +21 -0
  84. package/templates/angular/textarea/textarea.component.ts +268 -0
  85. package/templates/angular/textarea/textarea.module.ts +8 -0
  86. package/templates/angular/toast/toast.module.ts +8 -0
  87. package/templates/angular/toast/toast.service.ts +104 -0
  88. package/templates/angular/toast/toaster.component.ts +329 -0
  89. package/templates/angular/tooltip/tooltip-content.component.ts +43 -0
  90. package/templates/angular/tooltip/tooltip-trigger.component.ts +13 -0
  91. package/templates/angular/tooltip/tooltip.component.ts +243 -0
  92. package/templates/angular/tooltip/tooltip.module.ts +10 -0
@@ -0,0 +1,707 @@
1
+ import {
2
+ Component,
3
+ Input,
4
+ Output,
5
+ EventEmitter,
6
+ forwardRef,
7
+ OnInit,
8
+ ViewChild,
9
+ ElementRef,
10
+ HostListener,
11
+ } from '@angular/core';
12
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
13
+ import { cn, debounce } from '../utils';
14
+ import { LucideAngularModule } from 'lucide-angular';
15
+ import { CommonModule } from '@angular/common';
16
+
17
+ /**
18
+ * Select component types
19
+ */
20
+
21
+ export type SelectSize = 'sm' | 'md' | 'lg';
22
+ export type SelectVariant = 'default' | 'error' | 'success';
23
+
24
+ export interface SelectOption<T = string> {
25
+ label: string;
26
+ value: T;
27
+ disabled?: boolean;
28
+ group?: string;
29
+ }
30
+
31
+ export interface SelectProps<T = string> {
32
+ size?: SelectSize;
33
+ variant?: SelectVariant;
34
+ disabled?: boolean;
35
+ required?: boolean;
36
+ placeholder?: string;
37
+ options: SelectOption<T>[];
38
+ value?: T;
39
+ name?: string;
40
+ id?: string;
41
+ multiple?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Autocomplete component types
46
+ */
47
+
48
+
49
+ /**
50
+ * - `'free'`: allows free text input beyond suggestions (default)
51
+ * - `'strict'`: only values from the options list are accepted (searchable select)
52
+ */
53
+ export type AutocompleteMode = 'free' | 'strict';
54
+
55
+ export interface AutocompleteProps<T = string> {
56
+ size?: SelectSize;
57
+ variant?: SelectVariant;
58
+ disabled?: boolean;
59
+ required?: boolean;
60
+ placeholder?: string;
61
+ options: SelectOption<T>[];
62
+ value?: T | T[];
63
+ name?: string;
64
+ id?: string;
65
+ multiple?: boolean;
66
+ searchable?: boolean;
67
+ filterable?: boolean;
68
+ loading?: boolean;
69
+ noResultsText?: string;
70
+ minSearchLength?: number;
71
+ mode?: AutocompleteMode;
72
+ }
73
+
74
+
75
+ @Component({
76
+ selector: 'salas-autocomplete',
77
+ standalone: true,
78
+ imports: [FormsModule, CommonModule, LucideAngularModule],
79
+ template: `
80
+ <div [class]="wrapperClasses" #wrapper>
81
+ <div [class]="inputWrapperClasses">
82
+ <span class="salas-autocomplete-search-icon" aria-hidden="true">
83
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
84
+ <circle cx="11" cy="11" r="8"/>
85
+ <path d="m21 21-4.3-4.3"/>
86
+ </svg>
87
+ </span>
88
+ <input
89
+ [id]="id"
90
+ [name]="name"
91
+ [disabled]="disabled"
92
+ [required]="required"
93
+ [placeholder]="currentPlaceholder"
94
+ [class]="inputClasses"
95
+ [value]="inputDisplayValue"
96
+ (input)="onInput($event)"
97
+ (focus)="onFocus()"
98
+ (blur)="onBlur()"
99
+ (keydown)="onKeyDown($event)"
100
+ [attr.aria-invalid]="variant === 'error'"
101
+ [attr.aria-expanded]="isOpen"
102
+ [attr.aria-haspopup]="true"
103
+ [attr.aria-autocomplete]="mode === 'strict' ? 'both' : 'list'"
104
+ [attr.role]="'combobox'"
105
+ #inputEl
106
+ />
107
+ @if (displayValue && !disabled) {
108
+ <button
109
+ type="button"
110
+ class="salas-autocomplete-clear"
111
+ (click)="clearValue($event)"
112
+ aria-label="Clear"
113
+ >
114
+ <lucide-icon name="x" [size]="iconSize" />
115
+ </button>
116
+ }
117
+ @if (!displayValue || mode === 'strict') {
118
+ <lucide-icon
119
+ [name]="mode === 'strict' ? 'chevron-down' : (searchable ? '' : 'chevron-down')"
120
+ [size]="iconSize"
121
+ class="salas-autocomplete-icon"
122
+ [class.salas-autocomplete-icon--hidden]="mode === 'free' && searchable"
123
+ [class.salas-autocomplete-icon--rotate]="isOpen && mode === 'strict'"
124
+ />
125
+ }
126
+ </div>
127
+
128
+ @if (isOpen && filteredOptions.length > 0) {
129
+ <ul [class]="dropdownClasses" role="listbox">
130
+ @for (option of filteredOptions; track option.value) {
131
+ <li
132
+ [class]="getOptionClasses(option)"
133
+ (click)="selectOption(option)"
134
+ (mouseenter)="highlightedIndex = $index"
135
+ role="option"
136
+ [attr.aria-selected]="isSelected(option)"
137
+ >
138
+ {{ option.label }}
139
+ </li>
140
+ }
141
+ </ul>
142
+ }
143
+
144
+ @if (isOpen && filteredOptions.length === 0 && !loading) {
145
+ <div [class]="noResultsClasses">
146
+ {{ noResultsText }}
147
+ </div>
148
+ }
149
+
150
+ @if (loading) {
151
+ <div [class]="loadingClasses">
152
+ Loading...
153
+ </div>
154
+ }
155
+ </div>
156
+ `,
157
+ styles: [`
158
+ .salas-autocomplete-wrapper {
159
+ position: relative;
160
+ display: inline-block;
161
+ width: 100%;
162
+ }
163
+
164
+ .salas-autocomplete-input-wrapper {
165
+ position: relative;
166
+ display: flex;
167
+ align-items: center;
168
+ }
169
+
170
+ .salas-autocomplete {
171
+ position: relative;
172
+ z-index: 0;
173
+ width: 100%;
174
+ border-radius: 0.375rem;
175
+ border: 1px solid var(--salas-gray-300);
176
+ background-color: white;
177
+ padding: 0 0.75rem;
178
+ font-size: 0.875rem;
179
+ transition: all 0.2s;
180
+ outline: none;
181
+ }
182
+
183
+ .salas-autocomplete:focus {
184
+ border-color: var(--salas-primary-500);
185
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
186
+ }
187
+
188
+ .salas-autocomplete:disabled {
189
+ background-color: var(--salas-gray-50);
190
+ cursor: not-allowed;
191
+ opacity: 0.6;
192
+ }
193
+
194
+ .salas-autocomplete-search-icon {
195
+ position: absolute;
196
+ left: 0.75rem;
197
+ top: 50%;
198
+ transform: translateY(-50%);
199
+ display: inline-flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ width: 1.125rem;
203
+ height: 1.125rem;
204
+ color: var(--salas-gray-400);
205
+ pointer-events: none;
206
+ z-index: 2;
207
+ flex-shrink: 0;
208
+ }
209
+
210
+ .salas-autocomplete-search-icon svg {
211
+ width: 100%;
212
+ height: 100%;
213
+ display: block;
214
+ }
215
+
216
+ .salas-autocomplete-search-icon + .salas-autocomplete {
217
+ padding-left: 2.5rem;
218
+ }
219
+
220
+ .salas-autocomplete-icon,
221
+ .salas-autocomplete-clear {
222
+ position: absolute;
223
+ right: 0.75rem;
224
+ color: var(--salas-gray-400);
225
+ pointer-events: none;
226
+ z-index: 1;
227
+ transition: transform 0.2s;
228
+ }
229
+
230
+ .salas-autocomplete-icon--hidden {
231
+ display: none;
232
+ }
233
+
234
+ .salas-autocomplete-icon--rotate {
235
+ transform: rotate(180deg);
236
+ }
237
+
238
+ .salas-autocomplete-clear {
239
+ cursor: pointer;
240
+ pointer-events: all;
241
+ background: none;
242
+ border: none;
243
+ padding: 0.25rem;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ }
248
+
249
+ .salas-autocomplete-clear:hover {
250
+ color: var(--salas-gray-600);
251
+ }
252
+
253
+ /* Variants */
254
+ .salas-autocomplete--error {
255
+ border-color: var(--salas-destructive-500);
256
+ }
257
+
258
+ .salas-autocomplete--error:focus {
259
+ border-color: var(--salas-destructive-500);
260
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
261
+ }
262
+
263
+ .salas-autocomplete--success {
264
+ border-color: var(--salas-success-500);
265
+ }
266
+
267
+ .salas-autocomplete--success:focus {
268
+ border-color: var(--salas-success-500);
269
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1);
270
+ }
271
+
272
+ /* Sizes */
273
+ .salas-autocomplete--sm {
274
+ height: 2rem;
275
+ padding: 0 0.625rem;
276
+ font-size: 0.8125rem;
277
+ }
278
+
279
+ .salas-autocomplete--sm.salas-autocomplete-search-icon + .salas-autocomplete {
280
+ padding-left: 2rem;
281
+ }
282
+
283
+ .salas-autocomplete--md {
284
+ height: 2.5rem;
285
+ }
286
+
287
+ .salas-autocomplete--lg {
288
+ height: 3rem;
289
+ padding: 0 1rem;
290
+ font-size: 1rem;
291
+ }
292
+
293
+ .salas-autocomplete-dropdown {
294
+ position: absolute;
295
+ top: 100%;
296
+ left: 0;
297
+ right: 0;
298
+ margin-top: 0.25rem;
299
+ background-color: white;
300
+ border: 1px solid var(--salas-gray-200);
301
+ border-radius: 0.375rem;
302
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
303
+ max-height: 15rem;
304
+ overflow-y: auto;
305
+ z-index: 1000;
306
+ list-style: none;
307
+ padding: 0.25rem;
308
+ margin: 0;
309
+ }
310
+
311
+ .salas-autocomplete-option {
312
+ padding: 0.5rem 0.75rem;
313
+ cursor: pointer;
314
+ border-radius: 0.25rem;
315
+ font-size: 0.875rem;
316
+ }
317
+
318
+ .salas-autocomplete-option:hover,
319
+ .salas-autocomplete-option--highlighted {
320
+ background-color: var(--salas-gray-100);
321
+ }
322
+
323
+ .salas-autocomplete-option--selected {
324
+ background-color: var(--salas-primary-50);
325
+ color: var(--salas-primary-600);
326
+ }
327
+
328
+ .salas-autocomplete-option--disabled {
329
+ opacity: 0.5;
330
+ cursor: not-allowed;
331
+ }
332
+
333
+ .salas-autocomplete-no-results,
334
+ .salas-autocomplete-loading {
335
+ position: absolute;
336
+ top: 100%;
337
+ left: 0;
338
+ right: 0;
339
+ margin-top: 0.25rem;
340
+ padding: 0.75rem;
341
+ background-color: white;
342
+ border: 1px solid var(--salas-gray-200);
343
+ border-radius: 0.375rem;
344
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
345
+ text-align: center;
346
+ color: var(--salas-gray-500);
347
+ font-size: 0.875rem;
348
+ z-index: 1000;
349
+ }
350
+ `],
351
+ providers: [
352
+ {
353
+ provide: NG_VALUE_ACCESSOR,
354
+ useExisting: forwardRef(() => SalasAutocompleteComponent),
355
+ multi: true,
356
+ },
357
+ ],
358
+ })
359
+ export class SalasAutocompleteComponent<T = string>
360
+ implements ControlValueAccessor, OnInit, AutocompleteProps<T>
361
+ {
362
+ @ViewChild('wrapper', { static: true }) wrapper!: ElementRef<HTMLElement>;
363
+
364
+ @ViewChild('inputEl') inputEl?: ElementRef<HTMLInputElement>;
365
+
366
+ @Input() size: SelectSize = 'md';
367
+ @Input() variant: SelectVariant = 'default';
368
+ @Input() disabled = false;
369
+ @Input() required = false;
370
+ @Input() placeholder = '';
371
+ @Input() options: SelectOption<T>[] = [];
372
+ @Input() value?: T | T[];
373
+ @Input() name = '';
374
+ @Input() id = '';
375
+ @Input() multiple = false;
376
+ @Input() searchable = true;
377
+ @Input() filterable = true;
378
+ @Input() loading = false;
379
+ @Input() noResultsText = 'No results found';
380
+ @Input() minSearchLength = 0;
381
+ @Input() mode: AutocompleteMode = 'free';
382
+
383
+ @Output() valueChange = new EventEmitter<T | T[]>();
384
+ @Output() searchChange = new EventEmitter<string>();
385
+
386
+ isOpen = false;
387
+ searchQuery = '';
388
+ filteredOptions: SelectOption<T>[] = [];
389
+ highlightedIndex = -1;
390
+ displayValue = '';
391
+ private isSearching = false;
392
+
393
+ private onChange = (value: T | T[]) => {};
394
+ private onTouched = () => {};
395
+ private debouncedSearch = debounce((query: string) => {
396
+ this.searchChange.emit(query);
397
+ }, 300);
398
+
399
+ ngOnInit(): void {
400
+ this.filteredOptions = this.options;
401
+ this.updateDisplayValue();
402
+ }
403
+
404
+ @HostListener('document:click', ['$event'])
405
+ onClickOutside(event: MouseEvent): void {
406
+ if (!this.wrapper.nativeElement.contains(event.target as Node)) {
407
+ this.isOpen = false;
408
+ if (this.mode === 'strict') {
409
+ this.isSearching = false;
410
+ this.revertToSelectedValue();
411
+ } else if (this.isSearching) {
412
+ this.commitFreeText();
413
+ }
414
+ }
415
+ }
416
+
417
+ get wrapperClasses(): string {
418
+ return cn('salas-autocomplete-wrapper');
419
+ }
420
+
421
+ get inputWrapperClasses(): string {
422
+ return cn('salas-autocomplete-input-wrapper');
423
+ }
424
+
425
+ get inputClasses(): string {
426
+ return cn(
427
+ 'salas-autocomplete',
428
+ `salas-autocomplete--${this.size}`,
429
+ this.variant !== 'default' && `salas-autocomplete--${this.variant}`
430
+ );
431
+ }
432
+
433
+ get dropdownClasses(): string {
434
+ return cn('salas-autocomplete-dropdown');
435
+ }
436
+
437
+ get noResultsClasses(): string {
438
+ return cn('salas-autocomplete-no-results');
439
+ }
440
+
441
+ get loadingClasses(): string {
442
+ return cn('salas-autocomplete-loading');
443
+ }
444
+
445
+ get iconSize(): number {
446
+ return this.size === 'sm' ? 16 : this.size === 'lg' ? 20 : 18;
447
+ }
448
+
449
+ get currentPlaceholder(): string {
450
+ if (this.mode === 'strict' && this.isSearching && this.displayValue) {
451
+ return this.displayValue;
452
+ }
453
+ return this.placeholder;
454
+ }
455
+
456
+ get inputDisplayValue(): string {
457
+ if (this.mode === 'strict' && this.isSearching) {
458
+ return this.searchQuery;
459
+ }
460
+ return this.isSearching ? this.searchQuery : this.displayValue;
461
+ }
462
+
463
+ onInput(event: Event): void {
464
+ const target = event.target as HTMLInputElement;
465
+ this.searchQuery = target.value;
466
+ this.isSearching = true;
467
+ this.isOpen = true;
468
+ this.highlightedIndex = -1;
469
+
470
+ if (this.filterable) {
471
+ this.filterOptions();
472
+ }
473
+
474
+ if (this.searchQuery.length >= this.minSearchLength) {
475
+ this.debouncedSearch(this.searchQuery);
476
+ }
477
+ }
478
+
479
+ filterOptions(): void {
480
+ if (!this.searchQuery.trim()) {
481
+ this.filteredOptions = this.options;
482
+ return;
483
+ }
484
+
485
+ const query = this.searchQuery.toLowerCase();
486
+ this.filteredOptions = this.options.filter(
487
+ option =>
488
+ !option.disabled &&
489
+ option.label.toLowerCase().includes(query)
490
+ );
491
+ }
492
+
493
+ onFocus(): void {
494
+ if (!this.disabled) {
495
+ this.isOpen = true;
496
+ this.isSearching = false;
497
+ if (this.mode === 'strict') {
498
+ this.searchQuery = '';
499
+ this.filteredOptions = this.options;
500
+ } else if (this.filterable && !this.searchQuery) {
501
+ this.filteredOptions = this.options;
502
+ }
503
+ }
504
+ }
505
+
506
+ onBlur(): void {
507
+ setTimeout(() => {
508
+ if (this.wrapper.nativeElement.contains(document.activeElement)) {
509
+ return;
510
+ }
511
+
512
+ this.isOpen = false;
513
+
514
+ if (this.mode === 'strict') {
515
+ this.isSearching = false;
516
+ this.revertToSelectedValue();
517
+ } else if (this.isSearching) {
518
+ this.commitFreeText();
519
+ }
520
+
521
+ this.isSearching = false;
522
+ this.onTouched();
523
+ }, 200);
524
+ }
525
+
526
+ private revertToSelectedValue(): void {
527
+ this.searchQuery = '';
528
+ this.updateDisplayValue();
529
+ }
530
+
531
+ private commitFreeText(): void {
532
+ const text = this.searchQuery.trim();
533
+ this.searchQuery = '';
534
+ this.isSearching = false;
535
+ if (this.multiple) {
536
+ this.value = text ? ([text] as T[]) : ([] as T[]);
537
+ this.displayValue = text;
538
+ } else {
539
+ this.value = text as T;
540
+ this.displayValue = text;
541
+ }
542
+ this.onChange(this.value!);
543
+ this.valueChange.emit(this.value!);
544
+ }
545
+
546
+ private hasMatchingOption(query: string): boolean {
547
+ const q = query.toLowerCase().trim();
548
+ return this.options.some(o => o.label.toLowerCase() === q);
549
+ }
550
+
551
+ onKeyDown(event: KeyboardEvent): void {
552
+ if (!this.isOpen || this.filteredOptions.length === 0) {
553
+ if (event.key === 'ArrowDown' || event.key === 'Enter') {
554
+ this.isOpen = true;
555
+ this.filteredOptions = this.options;
556
+ }
557
+ return;
558
+ }
559
+
560
+ switch (event.key) {
561
+ case 'ArrowDown':
562
+ event.preventDefault();
563
+ this.highlightedIndex = Math.min(
564
+ this.highlightedIndex + 1,
565
+ this.filteredOptions.length - 1
566
+ );
567
+ break;
568
+ case 'ArrowUp':
569
+ event.preventDefault();
570
+ this.highlightedIndex = Math.max(this.highlightedIndex - 1, -1);
571
+ break;
572
+ case 'Enter':
573
+ event.preventDefault();
574
+ if (this.highlightedIndex >= 0) {
575
+ this.selectOption(this.filteredOptions[this.highlightedIndex]);
576
+ } else if (this.mode === 'strict' && this.filteredOptions.length === 1) {
577
+ this.selectOption(this.filteredOptions[0]);
578
+ } else if (this.mode === 'free' && this.searchQuery.trim()) {
579
+ this.commitFreeText();
580
+ this.isOpen = false;
581
+ }
582
+ break;
583
+ case 'Escape':
584
+ this.isOpen = false;
585
+ this.isSearching = false;
586
+ this.highlightedIndex = -1;
587
+ if (this.mode === 'strict') {
588
+ this.revertToSelectedValue();
589
+ }
590
+ break;
591
+ case 'Tab':
592
+ if (this.mode === 'strict') {
593
+ if (this.highlightedIndex >= 0) {
594
+ this.selectOption(this.filteredOptions[this.highlightedIndex]);
595
+ } else {
596
+ this.revertToSelectedValue();
597
+ }
598
+ } else if (this.mode === 'free' && this.isSearching) {
599
+ this.commitFreeText();
600
+ }
601
+ this.isOpen = false;
602
+ this.isSearching = false;
603
+ break;
604
+ }
605
+ }
606
+
607
+ selectOption(option: SelectOption<T>): void {
608
+ if (option.disabled) return;
609
+
610
+ if (this.multiple) {
611
+ const currentValues = Array.isArray(this.value) ? [...this.value] : [];
612
+ const index = currentValues.findIndex(v => this.isEqual(v, option.value));
613
+ if (index >= 0) {
614
+ currentValues.splice(index, 1);
615
+ } else {
616
+ currentValues.push(option.value);
617
+ }
618
+ this.value = currentValues as T[];
619
+ this.displayValue = currentValues
620
+ .map(v => this.options.find(o => this.isEqual(o.value, v))?.label)
621
+ .filter(Boolean)
622
+ .join(', ');
623
+ } else {
624
+ this.value = option.value;
625
+ this.displayValue = option.label;
626
+ this.isOpen = false;
627
+ }
628
+
629
+ this.searchQuery = '';
630
+ this.isSearching = false;
631
+ this.filteredOptions = this.options;
632
+
633
+ if (this.value !== undefined) {
634
+ this.onChange(this.value);
635
+ this.valueChange.emit(this.value);
636
+ }
637
+ }
638
+
639
+ clearValue(event: Event): void {
640
+ event.stopPropagation();
641
+ this.value = this.multiple ? ([] as T[]) : undefined;
642
+ this.displayValue = '';
643
+ this.searchQuery = '';
644
+ this.isSearching = false;
645
+ this.filteredOptions = this.options;
646
+ if (this.multiple) {
647
+ this.onChange([] as T[]);
648
+ this.valueChange.emit([] as T[]);
649
+ } else {
650
+ this.valueChange.emit(undefined as any);
651
+ }
652
+ }
653
+
654
+ isSelected(option: SelectOption<T>): boolean {
655
+ if (this.multiple && Array.isArray(this.value)) {
656
+ return this.value.some(v => this.isEqual(v, option.value));
657
+ }
658
+ if (this.value !== undefined) {
659
+ return this.isEqual(this.value as T, option.value);
660
+ }
661
+ return false;
662
+ }
663
+
664
+ getOptionClasses(option: SelectOption<T>): string {
665
+ return cn(
666
+ 'salas-autocomplete-option',
667
+ this.isSelected(option) && 'salas-autocomplete-option--selected',
668
+ option.disabled && 'salas-autocomplete-option--disabled',
669
+ this.highlightedIndex === this.filteredOptions.indexOf(option) &&
670
+ 'salas-autocomplete-option--highlighted'
671
+ );
672
+ }
673
+
674
+ updateDisplayValue(): void {
675
+ if (this.multiple && Array.isArray(this.value)) {
676
+ this.displayValue = this.value
677
+ .map(v => this.options.find(o => this.isEqual(o.value, v))?.label)
678
+ .filter(Boolean)
679
+ .join(', ');
680
+ } else if (this.value !== undefined) {
681
+ const option = this.options.find(o => this.isEqual(o.value, this.value as T));
682
+ this.displayValue = option?.label || '';
683
+ }
684
+ }
685
+
686
+ private isEqual(a: T, b: T): boolean {
687
+ return a === b;
688
+ }
689
+
690
+ // ControlValueAccessor implementation
691
+ writeValue(value: T | T[]): void {
692
+ this.value = value;
693
+ this.updateDisplayValue();
694
+ }
695
+
696
+ registerOnChange(fn: (value: T | T[]) => void): void {
697
+ this.onChange = fn;
698
+ }
699
+
700
+ registerOnTouched(fn: () => void): void {
701
+ this.onTouched = fn;
702
+ }
703
+
704
+ setDisabledState(isDisabled: boolean): void {
705
+ this.disabled = isDisabled;
706
+ }
707
+ }
@@ -0,0 +1,8 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { SalasAutocompleteComponent } from './autocomplete.component';
3
+
4
+ @NgModule({
5
+ imports: [SalasAutocompleteComponent],
6
+ exports: [SalasAutocompleteComponent],
7
+ })
8
+ export class SalasAutocompleteModule {}