Skip to content

Commit ff71339

Browse files
committed
feat: Add max selection limit, search debounce, and min search length
1 parent cd14776 commit ff71339

3 files changed

Lines changed: 104 additions & 10 deletions

File tree

src/lib/components/perfect-select/perfect-select.component.html

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,33 @@
110110

111111
<!-- Options List -->
112112
<div class="options-list" id="options-list">
113+
<!-- Max Selection Message -->
114+
@if (isMaxSelectionReached()) {
115+
<div class="info-message max-selection">
116+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
117+
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z"/>
118+
</svg>
119+
{{maxSelectedMessage}}
120+
</div>
121+
}
122+
123+
<!-- Min Search Length Message -->
124+
@if (showMinSearchMessage()) {
125+
<div class="info-message min-search">
126+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
127+
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z"/>
128+
</svg>
129+
{{minSearchMessage}} ({{searchTerm().length}}/{{minSearchLength}})
130+
</div>
131+
}
132+
113133
@if (isLoadingAsync()) {
114134
<div class="loading-message">{{loadingMessage()}}</div>
115-
} @else if (displayOptions().length === 0) {
135+
} @else if (displayOptions().length === 0 && !showMinSearchMessage()) {
116136
<div class="no-options">
117137
{{searchTerm() ? emptySearchText : emptyStateText}}
118138
</div>
119-
} @else {
139+
} @else if (!showMinSearchMessage()) {
120140
<!-- Select All (Multi-select only) -->
121141
@if (isMulti && showSelectAll && !searchTerm()) {
122142
<div class="select-all-container">
@@ -153,14 +173,14 @@
153173
class="option"
154174
[class.selected]="isSelected(option)"
155175
[class.highlighted]="idx === highlightedIndex()"
156-
[class.disabled]="isOptionDisabled(option)"
176+
[class.disabled]="isOptionDisabled(option) || (isMaxSelectionReached() && !isSelected(option))"
157177
[class.hidden]="hideSelectedOptions && isSelected(option)"
158178
[class.create-option]="option.__isCreate__"
159179
(click)="selectOption(option)"
160180
(keydown)="$event.key === 'Enter' && selectOption(option)"
161181
role="option"
162182
[attr.aria-selected]="isSelected(option)"
163-
[attr.aria-disabled]="isOptionDisabled(option)"
183+
[attr.aria-disabled]="isOptionDisabled(option) || (isMaxSelectionReached() && !isSelected(option))"
164184
tabindex="-1"
165185
>
166186
<!-- Checkbox for multi-select -->
@@ -220,14 +240,14 @@
220240
class="option"
221241
[class.selected]="isSelected(option)"
222242
[class.highlighted]="idx === highlightedIndex()"
223-
[class.disabled]="isOptionDisabled(option)"
243+
[class.disabled]="isOptionDisabled(option) || (isMaxSelectionReached() && !isSelected(option))"
224244
[class.hidden]="hideSelectedOptions && isSelected(option)"
225245
[class.create-option]="option.__isCreate__"
226246
(click)="selectOption(option)"
227247
(keydown)="$event.key === 'Enter' && selectOption(option)"
228248
role="option"
229249
[attr.aria-selected]="isSelected(option)"
230-
[attr.aria-disabled]="isOptionDisabled(option)"
250+
[attr.aria-disabled]="isOptionDisabled(option) || (isMaxSelectionReached() && !isSelected(option))"
231251
tabindex="-1"
232252
>
233253
<!-- Checkbox for multi-select -->

src/lib/components/perfect-select/perfect-select.component.scss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,33 @@
473473
font-size: 0.95em;
474474
}
475475

476+
/* v1.0.1 - Info Messages */
477+
.info-message {
478+
padding: 12px;
479+
margin: 8px;
480+
border-radius: 6px;
481+
font-size: 0.9em;
482+
display: flex;
483+
align-items: center;
484+
gap: 8px;
485+
486+
&.max-selection {
487+
background: #FFF4E5;
488+
color: #F57C00;
489+
border: 1px solid #FFE0B2;
490+
}
491+
492+
&.min-search {
493+
background: #E3F2FD;
494+
color: #1976D2;
495+
border: 1px solid #BBDEFB;
496+
}
497+
498+
svg {
499+
flex-shrink: 0;
500+
}
501+
}
502+
476503
/* RTL Support */
477504
.rtl .select-actions {
478505
flex-direction: row-reverse;

src/lib/components/perfect-select/perfect-select.component.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
110110
@Input() emptyStateText = 'No options available';
111111
@Input() emptySearchText = 'No results found';
112112

113+
// v1.0.1 Features
114+
@Input() maxSelectedOptions: number | null = null;
115+
@Input() maxSelectedMessage: string = 'Maximum selections reached';
116+
@Input() debounceTime: number = 300;
117+
@Input() minSearchLength: number = 0;
118+
@Input() minSearchMessage: string = 'Type to search...';
119+
113120
// Behavior
114121
@Input() name = 'angular-perfect-select';
115122
@Input() id = 'angular-perfect-select';
@@ -147,6 +154,7 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
147154
internalOptions = signal<SelectOption[]>([]);
148155
isLoadingAsync = signal(false);
149156
private optionsCache = new Map<string, SelectOption[]>();
157+
private debounceTimeout: any = null;
150158

151159
// Computed signals
152160
currentTheme = computed(() => THEMES[this.theme] || THEMES.blue);
@@ -155,6 +163,11 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
155163
const term = this.searchTerm();
156164
const opts = this.internalOptions();
157165

166+
// Check min search length
167+
if (this.minSearchLength > 0 && term.length < this.minSearchLength) {
168+
return [];
169+
}
170+
158171
if (!term) return opts;
159172

160173
return opts.filter(option => {
@@ -265,6 +278,19 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
265278
return selectedCount > 0 && selectedCount < opts.length;
266279
});
267280

281+
// v1.0.1 Computed signals
282+
isMaxSelectionReached = computed(() => {
283+
if (!this.isMulti || this.maxSelectedOptions === null) return false;
284+
const selected = this.selectedOptions();
285+
return selected.length >= this.maxSelectedOptions;
286+
});
287+
288+
showMinSearchMessage = computed(() => {
289+
if (this.minSearchLength === 0) return false;
290+
const term = this.searchTerm();
291+
return this.isOpen() && term.length > 0 && term.length < this.minSearchLength;
292+
});
293+
268294
// Helper method for template
269295
getEnabledOptionsCount(): number {
270296
return this.filteredOptions().filter(opt => !this.isOptionDisabled(opt)).length;
@@ -331,7 +357,10 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
331357
}
332358

333359
ngOnDestroy(): void {
334-
// Cleanup handled by DestroyRef
360+
// Clear debounce timeout
361+
if (this.debounceTimeout) {
362+
clearTimeout(this.debounceTimeout);
363+
}
335364
}
336365

337366
// Keyboard Navigation
@@ -461,6 +490,10 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
461490
if (exists) {
462491
newValue = currentValue.filter((v: any) => v !== optionValue);
463492
} else {
493+
// Check max selection limit
494+
if (this.maxSelectedOptions !== null && currentValue.length >= this.maxSelectedOptions) {
495+
return; // Don't allow selection beyond max
496+
}
464497
newValue = [...currentValue, optionValue];
465498
}
466499

@@ -521,7 +554,13 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
521554

522555
// Select All / Deselect All
523556
selectAll(): void {
524-
const opts = this.filteredOptions().filter(opt => !this.isOptionDisabled(opt));
557+
let opts = this.filteredOptions().filter(opt => !this.isOptionDisabled(opt));
558+
559+
// Respect max selection limit
560+
if (this.maxSelectedOptions !== null && opts.length > this.maxSelectedOptions) {
561+
opts = opts.slice(0, this.maxSelectedOptions);
562+
}
563+
525564
const values = opts.map(opt => this.getOptionValue(opt));
526565
this.internalValue.set(values);
527566
this.onChange(values);
@@ -550,9 +589,17 @@ export class PerfectSelectComponent implements ControlValueAccessor, OnInit, OnC
550589
action: 'input-change'
551590
});
552591

553-
// Trigger async loading if configured
592+
// Trigger async loading if configured with debounce
554593
if (this.loadOptions) {
555-
this.handleLoadOptions(term);
594+
// Clear existing timeout
595+
if (this.debounceTimeout) {
596+
clearTimeout(this.debounceTimeout);
597+
}
598+
599+
// Set new timeout for debounced loading
600+
this.debounceTimeout = setTimeout(() => {
601+
this.handleLoadOptions(term);
602+
}, this.debounceTime);
556603
}
557604
}
558605

0 commit comments

Comments
 (0)