@ng-vui/multi-select
v1.0.0
Published
Angular Multi-Select Dropdown Component for VUI Library
Maintainers
Readme
@ertpl-ui/multi-select
A fully-featured Angular multi-select dropdown component with built-in validation, error handling, and seamless form integration. Perfect for selecting multiple options with comprehensive validation and user-friendly feedback.
📚 Quick Navigation
| Section | Description | Perfect For | |---------|-------------|------------| | 🚀 Features | Complete feature overview | Understanding capabilities | | 📦 Installation | Setup and installation | Getting started | | 🎯 Basic Usage | Simple implementation examples | First-time setup | | 🔧 Configuration | Settings and options | Customization | | ✨ Advanced Features | Complex use cases | Advanced implementation | | 🎨 Styling & Theming | Visual customization | UI/UX design | | 🧪 Testing | Unit testing examples | Quality assurance | | 🚀 Performance | Optimization strategies | Large datasets |
🎯 Use Cases & Examples
Basic Multi-Selection
Perfect for simple multi-select dropdowns with basic validation.
<ng-multiselect-dropdown [data]="fruits" [(ngModel)]="selectedFruits"></ng-multiselect-dropdown>Form Integration
Seamless integration with Angular reactive forms and validation.
<ng-multiselect-dropdown [formControl]="categoriesControl" [data]="categories"></ng-multiselect-dropdown>Search & Filter
Built-in search functionality for large datasets.
<ng-multiselect-dropdown [data]="cities" [settings]="{allowSearchFilter: true}"></ng-multiselect-dropdown>Grouped Options
Organize options into logical groups with validation.
<ng-multiselect-dropdown [data]="employees" [settings]="{groupBy: 'department'}"></ng-multiselect-dropdown>Custom Templates
Customize how options and selections are displayed.
<ng-multiselect-dropdown [data]="users" [itemTemplate]="userTemplate"></ng-multiselect-dropdown>🚀 Features
- ✅ Multi-Selection - Select multiple items with checkboxes
- ✅ Built-in Validation - Supports all Angular validators with automatic error messages
- ✅ Error Handling - Visual error states with customizable error messages and icons
- ✅ Form Integration - Works seamlessly with FormControl and FormGroup
- ✅ Search & Filter - Built-in search functionality with filtering
- ✅ Grouping - Organize options into groups with validation
- ✅ Custom Templates - Customize option and selected item display
- ✅ Accessibility - Full ARIA support and keyboard navigation
- ✅ Virtual Scrolling - Handle large datasets efficiently
- ✅ Selection Limits - Min/max selection validation
- ✅ Required Indicators - Visual asterisk for required fields
- ✅ TypeScript - Full type safety and IntelliSense support
📦 Installation
npm install @ertpl-ui/multi-select⚡ Quick Start
1. Small Dataset (< 100 options)
const basicSettings = {
singleSelection: false,
idField: 'id',
textField: 'name',
selectAllText: 'Select All',
unSelectAllText: 'Clear All',
itemsShowLimit: 3,
allowSearchFilter: false
};2. Medium Dataset (100-1000 options)
const mediumSettings = {
singleSelection: false,
idField: 'id',
textField: 'name',
allowSearchFilter: true,
searchPlaceholderText: 'Search options...',
itemsShowLimit: 5,
enableCheckAll: true
};3. Large Dataset (1000+ options)
const largeSettings = {
singleSelection: false,
idField: 'id',
textField: 'name',
allowSearchFilter: true,
lazyLoading: true,
virtualScrolling: true,
itemHeight: 35,
limitSelection: 20
};🎯 Basic Usage
Import
import { MultiselectComponent } from '@ertpl-ui/multi-select';
// For standalone components
@Component({
standalone: true,
imports: [MultiselectComponent],
// ... component config
})
// Or import in module
@NgModule({
imports: [MultiselectComponent],
// ... module config
})Simple Multi-Select
@Component({
selector: 'app-basic-example',
template: `
<ng-multiselect-dropdown
[placeholder]="'Select items'"
[data]="dropdownList"
[(ngModel)]="selectedItems"
[settings]="MultiSelectSettings">
</ng-multiselect-dropdown>
<div class="mt-3">
<h5>Selected Items:</h5>
<ul>
<li *ngFor="let item of selectedItems">{{ item.item_text }}</li>
</ul>
</div>
`
})
export class BasicExampleComponent {
dropdownList = [
{ item_id: 1, item_text: 'Apple' },
{ item_id: 2, item_text: 'Banana' },
{ item_id: 3, item_text: 'Orange' },
{ item_id: 4, item_text: 'Mango' },
{ item_id: 5, item_text: 'Grapes' }
];
selectedItems = [];
MultiSelectSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'UnSelect All',
itemsShowLimit: 3,
allowSearchFilter: true
};
}With Reactive Forms and Validation
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, ReactiveFormsModule } from '@angular/forms';
import { MultiselectComponent } from '@ertpl-ui/multi-select';
@Component({
standalone: true,
imports: [ReactiveFormsModule, MultiselectComponent],
template: `
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<div class="space-y-4">
<div class="form-group">
<label class="block text-sm font-medium mb-1">Skills (Required) *</label>
<ng-multiselect-dropdown
formControlName="skills"
[placeholder]="'Select your skills'"
[data]="skillsList"
[settings]="skillsSettings"
[class]="getInputClasses('skills')">
</ng-multiselect-dropdown>
<div *ngIf="getSkillsError()" class="mt-1 text-sm text-red-600">
{{ getSkillsError() }}
</div>
</div>
<div class="form-group">
<label class="block text-sm font-medium mb-1">Interests *</label>
<ng-multiselect-dropdown
formControlName="interests"
[placeholder]="'Choose interests (2-5)'"
[data]="interestsList"
[settings]="interestsSettings"
[class]="getInputClasses('interests')">
</ng-multiselect-dropdown>
<div *ngIf="getInterestsError()" class="mt-1 text-sm text-red-600">
{{ getInterestsError() }}
</div>
<div class="mt-1 text-xs text-gray-600">
Selected: {{ getSelectionCount('interests') }}/5
</div>
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Save Profile
</button>
</div>
</form>
`
})
export class ReactiveFormComponent implements OnInit {
userForm!: FormGroup;
isSubmitted = false;
skillsList = [
{ item_id: 1, item_text: 'JavaScript' },
{ item_id: 2, item_text: 'Angular' },
{ item_id: 3, item_text: 'React' },
{ item_id: 4, item_text: 'Node.js' },
{ item_id: 5, item_text: 'Python' },
{ item_id: 6, item_text: 'Java' },
{ item_id: 7, item_text: 'C#' },
{ item_id: 8, item_text: 'TypeScript' }
];
interestsList = [
{ item_id: 1, item_text: 'Web Development' },
{ item_id: 2, item_text: 'Mobile Apps' },
{ item_id: 3, item_text: 'Machine Learning' },
{ item_id: 4, item_text: 'Data Science' },
{ item_id: 5, item_text: 'DevOps' },
{ item_id: 6, item_text: 'UI/UX Design' },
{ item_id: 7, item_text: 'Cloud Computing' },
{ item_id: 8, item_text: 'Cybersecurity' }
];
skillsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'Unselect All',
itemsShowLimit: 3,
allowSearchFilter: true,
maxHeight: 200
};
interestsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'Unselect All',
itemsShowLimit: 2,
allowSearchFilter: true,
limitSelection: 5,
maxHeight: 200
};
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.userForm = this.fb.group({
skills: [[], [
Validators.required,
this.minSelectedValidator(1),
this.maxSelectedValidator(4)
]],
interests: [[], [
Validators.required,
this.minSelectedValidator(2),
this.maxSelectedValidator(5),
this.uniqueSelectionValidator.bind(this)
]]
});
}
minSelectedValidator(min: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value || [];
return value.length >= min ? null : { minSelected: { min, actual: value.length } };
};
}
maxSelectedValidator(max: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value || [];
return value.length <= max ? null : { maxSelected: { max, actual: value.length } };
};
}
uniqueSelectionValidator(control: AbstractControl): ValidationErrors | null {
const selectedInterests = control.value || [];
const selectedSkills = this.userForm?.get('skills')?.value || [];
if (selectedInterests.length === 0) return null;
// Check if interests overlap with skills (business rule)
const skillIds = selectedSkills.map((skill: any) => skill.item_id);
const interestIds = selectedInterests.map((interest: any) => interest.item_id);
const hasOverlap = skillIds.some((id: number) => interestIds.includes(id));
if (hasOverlap) {
return { overlappingSelection: true };
}
return null;
}
getInputClasses(fieldName: string): string {
const control = this.userForm.get(fieldName);
const hasError = control?.invalid && (control?.touched || this.isSubmitted);
return hasError ? 'border-red-500 ring-red-500' : 'border-gray-300';
}
getSelectionCount(fieldName: string): number {
const control = this.userForm.get(fieldName);
return control?.value?.length || 0;
}
getSkillsError(): string | null {
const control = this.userForm.get('skills');
if (!control || (!control.touched && !this.isSubmitted)) return null;
if (control.errors?.['required']) {
return 'Please select at least one skill';
}
if (control.errors?.['minSelected']) {
const min = control.errors['minSelected'].min;
return `Select at least ${min} skill(s)`;
}
if (control.errors?.['maxSelected']) {
const max = control.errors['maxSelected'].max;
return `Select at most ${max} skills`;
}
return null;
}
getInterestsError(): string | null {
const control = this.userForm.get('interests');
if (!control || (!control.touched && !this.isSubmitted)) return null;
if (control.errors?.['required']) {
return 'Please select your interests';
}
if (control.errors?.['minSelected']) {
const min = control.errors['minSelected'].min;
return `Select at least ${min} interests`;
}
if (control.errors?.['maxSelected']) {
const max = control.errors['maxSelected'].max;
return `Select at most ${max} interests`;
}
if (control.errors?.['overlappingSelection']) {
return 'Interests cannot overlap with selected skills';
}
return null;
}
onSubmit() {
this.isSubmitted = true;
if (this.userForm.valid) {
console.log('Form submitted:', this.userForm.value);
}
}
}🚨 Error Handling & Validation
Built-in Validation Support
The VUI multi-select component provides comprehensive validation capabilities for multiple selection scenarios with built-in error handling and visual feedback:
Required Validation
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label class="block text-sm font-medium mb-1">Categories *</label>
<ng-multiselect-dropdown
formControlName="categories"
[placeholder]="'Select categories'"
[data]="categoryList"
[settings]="categorySettings"
[class]="getCategoryClasses()">
</ng-multiselect-dropdown>
<div *ngIf="getCategoryError()" class="mt-1 text-sm text-red-600">
{{ getCategoryError() }}
</div>
</div>
</form>
`
})
export class RequiredValidationExample {
form = this.fb.group({
categories: [[], Validators.required] // Required multi-selection
});
categoryList = [
{ item_id: 1, item_text: 'Technology' },
{ item_id: 2, item_text: 'Business' },
{ item_id: 3, item_text: 'Design' },
{ item_id: 4, item_text: 'Marketing' }
];
categorySettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'Unselect All',
itemsShowLimit: 2,
allowSearchFilter: true
};
submitted = false;
getCategoryClasses(): string {
const control = this.form.get('categories');
const hasError = control?.invalid && (control?.touched || this.submitted);
return hasError ? 'border-red-500' : 'border-gray-300';
}
getCategoryError(): string | null {
const control = this.form.get('categories');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select at least one category';
}
return null;
}
onSubmit() {
this.submitted = true;
if (this.form.valid) {
console.log('Categories selected:', this.form.value);
}
}
}Min/Max Selection Validation
@Component({
template: `
<form [formGroup]="teamForm" (ngSubmit)="onSubmit()">
<div class="space-y-4">
<div class="form-group">
<label>Team Members (2-5 required) *</label>
<ng-multiselect-dropdown
formControlName="teamMembers"
[placeholder]="'Select 2-5 team members'"
[data]="membersList"
[settings]="membersSettings"
[class]="getFieldClasses('teamMembers')">
</ng-multiselect-dropdown>
<div *ngIf="getTeamMembersError()" class="mt-1 text-sm text-red-600">
{{ getTeamMembersError() }}
</div>
<div class="mt-1 text-xs text-gray-600">
Selected: {{ getSelectionCount('teamMembers') }}/5
</div>
</div>
<div class="form-group">
<label>Project Tags (1-8 maximum) *</label>
<ng-multiselect-dropdown
formControlName="tags"
[placeholder]="'Add project tags'"
[data]="tagsList"
[settings]="tagsSettings"
[class]="getFieldClasses('tags')">
</ng-multiselect-dropdown>
<div *ngIf="getTagsError()" class="mt-1 text-sm text-red-600">
{{ getTagsError() }}
</div>
<div class="mt-1 text-xs text-gray-600">
Tags: {{ getSelectionCount('tags') }}/8 maximum
</div>
</div>
</div>
</form>
`
})
export class MinMaxValidationExample implements OnInit {
teamForm = this.fb.group({
teamMembers: [[], [
Validators.required,
this.minSelectionValidator(2),
this.maxSelectionValidator(5)
]],
tags: [[], [
Validators.required,
this.minSelectionValidator(1),
this.maxSelectionValidator(8)
]]
});
membersList = [
{ item_id: 1, item_text: 'Alice Johnson' },
{ item_id: 2, item_text: 'Bob Smith' },
{ item_id: 3, item_text: 'Carol Davis' },
{ item_id: 4, item_text: 'David Wilson' },
{ item_id: 5, item_text: 'Eva Brown' },
{ item_id: 6, item_text: 'Frank Miller' },
{ item_id: 7, item_text: 'Grace Lee' }
];
tagsList = [
{ item_id: 1, item_text: 'urgent' },
{ item_id: 2, item_text: 'frontend' },
{ item_id: 3, item_text: 'backend' },
{ item_id: 4, item_text: 'database' },
{ item_id: 5, item_text: 'api' },
{ item_id: 6, item_text: 'testing' },
{ item_id: 7, item_text: 'deployment' },
{ item_id: 8, item_text: 'documentation' },
{ item_id: 9, item_text: 'refactoring' },
{ item_id: 10, item_text: 'optimization' }
];
membersSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'Unselect All',
itemsShowLimit: 2,
allowSearchFilter: true,
limitSelection: 5
};
tagsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
selectAllText: 'Select All',
unSelectAllText: 'Unselect All',
itemsShowLimit: 3,
allowSearchFilter: true,
limitSelection: 8
};
submitted = false;
ngOnInit() {
// Watch for team member changes to validate business rules
this.teamForm.get('teamMembers')?.valueChanges.subscribe(() => {
this.teamForm.get('tags')?.updateValueAndValidity();
});
}
minSelectionValidator(min: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value || [];
return value.length >= min ? null : {
minSelection: { required: min, actual: value.length }
};
};
}
maxSelectionValidator(max: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value || [];
return value.length <= max ? null : {
maxSelection: { allowed: max, actual: value.length }
};
};
}
getFieldClasses(fieldName: string): string {
const control = this.teamForm.get(fieldName);
const hasError = control?.invalid && (control?.touched || this.submitted);
return hasError ? 'border-red-500 ring-red-500' : 'border-gray-300';
}
getSelectionCount(fieldName: string): number {
const control = this.teamForm.get(fieldName);
return control?.value?.length || 0;
}
getTeamMembersError(): string | null {
const control = this.teamForm.get('teamMembers');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select team members for this project';
}
if (control.errors?.['minSelection']) {
const min = control.errors['minSelection'].required;
return `Select at least ${min} team members`;
}
if (control.errors?.['maxSelection']) {
const max = control.errors['maxSelection'].allowed;
return `Select at most ${max} team members`;
}
return null;
}
getTagsError(): string | null {
const control = this.teamForm.get('tags');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please add at least one project tag';
}
if (control.errors?.['minSelection']) {
return 'Add at least one tag to categorize this project';
}
if (control.errors?.['maxSelection']) {
return 'Too many tags selected (8 maximum)';
}
return null;
}
onSubmit() {
this.submitted = true;
if (this.teamForm.valid) {
console.log('Team configuration:', this.teamForm.value);
}
}
}Custom Business Logic Validation
@Component({
template: `
<form [formGroup]="courseForm" (ngSubmit)="onSubmit()">
<div class="space-y-4">
<div class="form-group">
<label>Prerequisites *</label>
<ng-multiselect-dropdown
formControlName="prerequisites"
[placeholder]="'Select prerequisite courses'"
[data]="coursesList"
[settings]="prerequisiteSettings">
</ng-multiselect-dropdown>
<div *ngIf="getPrerequisitesError()" class="mt-1 text-sm text-red-600">
{{ getPrerequisitesError() }}
</div>
</div>
<div class="form-group">
<label>Student Levels *</label>
<ng-multiselect-dropdown
formControlName="levels"
[placeholder]="'Target student levels'"
[data]="levelsList"
[settings]="levelsSettings">
</ng-multiselect-dropdown>
<div *ngIf="getLevelsError()" class="mt-1 text-sm text-red-600">
{{ getLevelsError() }}
</div>
</div>
<div class="form-group">
<label>Required Skills *</label>
<ng-multiselect-dropdown
formControlName="requiredSkills"
[placeholder]="'Skills students must have'"
[data]="skillsList"
[settings]="skillsSettings">
</ng-multiselect-dropdown>
<div *ngIf="getSkillsError()" class="mt-1 text-sm text-red-600">
{{ getSkillsError() }}
</div>
</div>
</div>
</form>
`
})
export class BusinessLogicValidationExample implements OnInit {
courseForm = this.fb.group({
prerequisites: [[], [
Validators.required,
this.prerequisiteValidator.bind(this)
]],
levels: [[], [
Validators.required,
this.levelCompatibilityValidator.bind(this)
]],
requiredSkills: [[], [
Validators.required,
this.skillsAlignmentValidator.bind(this)
]]
});
coursesList = [
{ item_id: 1, item_text: 'Basic Programming', level: 'beginner' },
{ item_id: 2, item_text: 'Data Structures', level: 'intermediate' },
{ item_id: 3, item_text: 'Algorithms', level: 'intermediate' },
{ item_id: 4, item_text: 'Database Design', level: 'intermediate' },
{ item_id: 5, item_text: 'Advanced JavaScript', level: 'advanced' }
];
levelsList = [
{ item_id: 1, item_text: 'Beginner', value: 'beginner' },
{ item_id: 2, item_text: 'Intermediate', value: 'intermediate' },
{ item_id: 3, item_text: 'Advanced', value: 'advanced' },
{ item_id: 4, item_text: 'Expert', value: 'expert' }
];
skillsList = [
{ item_id: 1, item_text: 'JavaScript', category: 'programming' },
{ item_id: 2, item_text: 'HTML/CSS', category: 'web' },
{ item_id: 3, item_text: 'SQL', category: 'database' },
{ item_id: 4, item_text: 'Git', category: 'tools' },
{ item_id: 5, item_text: 'Problem Solving', category: 'soft' },
{ item_id: 6, item_text: 'Team Work', category: 'soft' }
];
prerequisiteSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
allowSearchFilter: true,
itemsShowLimit: 2
};
levelsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
allowSearchFilter: false,
limitSelection: 3
};
skillsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
allowSearchFilter: true,
groupBy: 'category'
};
submitted = false;
ngOnInit() {
// Cross-field validation updates
this.courseForm.get('prerequisites')?.valueChanges.subscribe(() => {
this.courseForm.get('levels')?.updateValueAndValidity();
});
this.courseForm.get('levels')?.valueChanges.subscribe(() => {
this.courseForm.get('requiredSkills')?.updateValueAndValidity();
});
}
prerequisiteValidator(control: AbstractControl): ValidationErrors | null {
const prerequisites = control.value || [];
if (prerequisites.length === 0) return null;
// Check for circular dependencies
const advancedPrereqs = prerequisites.filter((p: any) =>
this.coursesList.find(c => c.item_id === p.item_id && c.level === 'advanced')
);
if (advancedPrereqs.length > 2) {
return { tooManyAdvancedPrereqs: true };
}
// Ensure logical progression
const hasBasic = prerequisites.some((p: any) => p.item_id === 1); // Basic Programming
const hasIntermediate = prerequisites.some((p: any) => [2, 3, 4].includes(p.item_id));
if (hasIntermediate && !hasBasic) {
return { missingFoundation: true };
}
return null;
}
levelCompatibilityValidator(control: AbstractControl): ValidationErrors | null {
const levels = control.value || [];
const prerequisites = this.courseForm?.get('prerequisites')?.value || [];
if (levels.length === 0) return null;
// Check if selected levels match prerequisite complexity
const hasAdvancedLevel = levels.some((l: any) => ['advanced', 'expert'].includes(l.value));
const hasAdvancedPrereqs = prerequisites.some((p: any) =>
this.coursesList.find(c => c.item_id === p.item_id && c.level === 'advanced')
);
if (hasAdvancedLevel && !hasAdvancedPrereqs) {
return { levelMismatch: true };
}
// Cannot mix beginner with expert
const hasBeginner = levels.some((l: any) => l.value === 'beginner');
const hasExpert = levels.some((l: any) => l.value === 'expert');
if (hasBeginner && hasExpert) {
return { conflictingLevels: true };
}
return null;
}
skillsAlignmentValidator(control: AbstractControl): ValidationErrors | null {
const skills = control.value || [];
const levels = this.courseForm?.get('levels')?.value || [];
if (skills.length === 0) return null;
// Must have at least one technical and one soft skill
const technicalSkills = skills.filter((s: any) =>
['programming', 'web', 'database', 'tools'].includes(
this.skillsList.find(skill => skill.item_id === s.item_id)?.category
)
);
const softSkills = skills.filter((s: any) =>
this.skillsList.find(skill => skill.item_id === s.item_id)?.category === 'soft'
);
if (technicalSkills.length === 0) {
return { noTechnicalSkills: true };
}
if (softSkills.length === 0) {
return { noSoftSkills: true };
}
// Advanced levels require more technical skills
const hasAdvancedLevels = levels.some((l: any) => ['advanced', 'expert'].includes(l.value));
if (hasAdvancedLevels && technicalSkills.length < 2) {
return { insufficientTechnicalSkills: true };
}
return null;
}
getPrerequisitesError(): string | null {
const control = this.courseForm.get('prerequisites');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select prerequisite courses';
}
if (control.errors?.['tooManyAdvancedPrereqs']) {
return 'Too many advanced prerequisites (maximum 2)';
}
if (control.errors?.['missingFoundation']) {
return 'Basic Programming is required for intermediate courses';
}
return null;
}
getLevelsError(): string | null {
const control = this.courseForm.get('levels');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select target student levels';
}
if (control.errors?.['levelMismatch']) {
return 'Advanced levels require advanced prerequisites';
}
if (control.errors?.['conflictingLevels']) {
return 'Cannot target both beginner and expert levels';
}
return null;
}
getSkillsError(): string | null {
const control = this.courseForm.get('requiredSkills');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select required student skills';
}
if (control.errors?.['noTechnicalSkills']) {
return 'At least one technical skill is required';
}
if (control.errors?.['noSoftSkills']) {
return 'At least one soft skill is required';
}
if (control.errors?.['insufficientTechnicalSkills']) {
return 'Advanced courses require at least 2 technical skills';
}
return null;
}
onSubmit() {
this.submitted = true;
if (this.courseForm.valid) {
console.log('Course configuration:', this.courseForm.value);
}
}
}Group-Based Validation
@Component({
template: `
<form [formGroup]="permissionsForm">
<div class="space-y-4">
<div class="form-group">
<label>User Permissions *</label>
<ng-multiselect-dropdown
formControlName="permissions"
[placeholder]="'Select user permissions'"
[data]="permissionsList"
[settings]="permissionsSettings">
</ng-multiselect-dropdown>
<div *ngIf="getPermissionsError()" class="mt-1 text-sm text-red-600">
{{ getPermissionsError() }}
</div>
<div class="mt-2 text-xs text-gray-600">
<div>Read permissions: {{ getPermissionsByGroup('read').length }}</div>
<div>Write permissions: {{ getPermissionsByGroup('write').length }}</div>
<div>Admin permissions: {{ getPermissionsByGroup('admin').length }}</div>
</div>
</div>
</div>
</form>
`
})
export class GroupValidationExample {
permissionsForm = this.fb.group({
permissions: [[], [
Validators.required,
this.permissionGroupValidator.bind(this)
]]
});
permissionsList = [
{ item_id: 1, item_text: 'View Users', group: 'read' },
{ item_id: 2, item_text: 'View Reports', group: 'read' },
{ item_id: 3, item_text: 'View Settings', group: 'read' },
{ item_id: 4, item_text: 'Edit Users', group: 'write' },
{ item_id: 5, item_text: 'Create Reports', group: 'write' },
{ item_id: 6, item_text: 'Edit Settings', group: 'write' },
{ item_id: 7, item_text: 'Delete Users', group: 'admin' },
{ item_id: 8, item_text: 'System Config', group: 'admin' },
{ item_id: 9, item_text: 'User Management', group: 'admin' }
];
permissionsSettings = {
singleSelection: false,
idField: 'item_id',
textField: 'item_text',
groupBy: 'group',
allowSearchFilter: true,
itemsShowLimit: 3
};
submitted = false;
permissionGroupValidator(control: AbstractControl): ValidationErrors | null {
const permissions = control.value || [];
if (permissions.length === 0) return null;
const readPerms = this.getPermissionsByGroup('read', permissions);
const writePerms = this.getPermissionsByGroup('write', permissions);
const adminPerms = this.getPermissionsByGroup('admin', permissions);
// Must have at least one read permission
if (readPerms.length === 0) {
return { noReadPermissions: true };
}
// Cannot have write permissions without corresponding read permissions
if (writePerms.length > 0 && readPerms.length === 0) {
return { writeWithoutRead: true };
}
// Cannot have admin permissions without write permissions
if (adminPerms.length > 0 && writePerms.length === 0) {
return { adminWithoutWrite: true };
}
// Admin permissions are limited
if (adminPerms.length > 2) {
return { tooManyAdminPermissions: { max: 2, actual: adminPerms.length } };
}
return null;
}
getPermissionsByGroup(group: string, permissions?: any[]): any[] {
const perms = permissions || this.permissionsForm.get('permissions')?.value || [];
return perms.filter((p: any) =>
this.permissionsList.find(perm => perm.item_id === p.item_id && perm.group === group)
);
}
getPermissionsError(): string | null {
const control = this.permissionsForm.get('permissions');
if (!control || (!control.touched && !this.submitted)) return null;
if (control.errors?.['required']) {
return 'Please select user permissions';
}
if (control.errors?.['noReadPermissions']) {
return 'At least one read permission is required';
}
if (control.errors?.['writeWithoutRead']) {
return 'Write permissions require corresponding read permissions';
}
if (control.errors?.['adminWithoutWrite']) {
return 'Admin permissions require write permissions';
}
if (control.errors?.['tooManyAdminPermissions']) {
const max = control.errors['tooManyAdminPermissions'].max;
return `Too many admin permissions (maximum ${max})`;
}
return null;
}
}Common Validation Patterns
Dynamic Selection Limits
// Dynamic limit based on user role
getRoleBasedLimit(userRole: string): number {
const limits = {
'admin': 10,
'manager': 5,
'user': 3
};
return limits[userRole as keyof typeof limits] || 1;
}
// Usage in validator
roleLimitValidator(control: AbstractControl): ValidationErrors | null {
const selections = control.value || [];
const userRole = this.getCurrentUserRole(); // Your method to get role
const limit = this.getRoleBasedLimit(userRole);
return selections.length <= limit ? null : {
roleLimit: { role: userRole, limit, actual: selections.length }
};
}Conditional Required Validation
// Conditional validator
conditionalRequiredValidator(dependentField: string) {
return (control: AbstractControl): ValidationErrors | null => {
const dependentValue = this.form?.get(dependentField)?.value;
const currentValue = control.value || [];
// Required only if dependent field has value
if (dependentValue && currentValue.length === 0) {
return { conditionallyRequired: { dependsOn: dependentField } };
}
return null;
};
}🔧 API Reference
Component Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| data | any[] | [] | Array of options |
| settings | IMultiSelectSettings | {} | Configuration object |
| placeholder | string | 'Select' | Placeholder text |
| disabled | boolean | false | Disable the component |
| loading | boolean | false | Show loading indicator |
Events
| Event | Type | Description |
|-------|------|-------------|
| onSelect | EventEmitter<any> | Item selected |
| onDeSelect | EventEmitter<any> | Item deselected |
| onSelectAll | EventEmitter<any[]> | All items selected |
| onDeSelectAll | EventEmitter<any[]> | All items deselected |
| onFilterChange | EventEmitter<any> | Search filter changed |
| onDropDownClose | EventEmitter<any> | Dropdown closed |
Settings Interface
interface IMultiSelectSettings {
singleSelection?: boolean; // Single selection mode
idField?: string; // ID property name
textField?: string; // Display text property name
enableCheckAll?: boolean; // Show select/deselect all
selectAllText?: string; // Select all button text
unSelectAllText?: string; // Deselect all button text
allowSearchFilter?: boolean; // Enable search
clearSearchFilter?: boolean; // Clear search on selection
maxHeight?: number; // Dropdown max height
itemsShowLimit?: number; // Max items to show in selection display
limitSelection?: number; // Max items that can be selected
searchPlaceholderText?: string; // Search input placeholder
noDataAvailablePlaceholderText?: string; // No data message
closeDropDownOnSelection?: boolean; // Close dropdown after selection
showSelectedItemsAtTop?: boolean; // Show selected items at top
defaultOpen?: boolean; // Open dropdown by default
allowRemoteDataSearch?: boolean; // Enable remote search
lazyLoading?: boolean; // Enable lazy loading
tagToBody?: boolean; // Append dropdown to body
labelKey?: string; // Custom label key
primaryKey?: string; // Custom primary key
position?: 'top' | 'bottom'; // Dropdown position
}🎨 Grouping
@Component({
template: `
<ng-multiselect-dropdown
[data]="groupedData"
[(ngModel)]="selectedItems"
[settings]="groupSettings">
</ng-multiselect-dropdown>
`
})
export class GroupingExampleComponent {
groupedData = [
{
category: 'Fruits',
options: [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Orange' }
]
},
{
category: 'Vegetables',
options: [
{ id: 4, name: 'Carrot' },
{ id: 5, name: 'Broccoli' },
{ id: 6, name: 'Spinach' }
]
}
];
selectedItems = [];
groupSettings = {
singleSelection: false,
idField: 'id',
textField: 'name',
enableCheckAll: true,
itemsShowLimit: 3,
allowSearchFilter: true,
// Group settings
groupBy: 'category',
enableGroup: true
};
}🎯 Advanced Examples
Custom Templates
<ng-multiselect-dropdown
[data]="users"
[(ngModel)]="selectedUsers"
[settings]="settings">
<!-- Custom option template -->
<ng-template #optionTemplate let-option="option">
<div class="d-flex align-items-center">
<img [src]="option.avatar" class="rounded-circle me-2" width="24" height="24">
<div>
<div class="fw-medium">{{ option.name }}</div>
<small class="text-muted">{{ option.email }}</small>
</div>
</div>
</ng-template>
<!-- Custom selected item template -->
<ng-template #selectedTemplate let-item="item">
<div class="badge bg-primary me-1">
<img [src]="item.avatar" class="rounded-circle me-1" width="16" height="16">
{{ item.name }}
<button (click)="removeItem(item)" class="btn-close btn-close-white ms-1"></button>
</div>
</ng-template>
</ng-multiselect-dropdown>Remote Data Loading
@Component({
template: `
<ng-multiselect-dropdown
[data]="options"
[(ngModel)]="selectedItems"
[settings]="remoteSettings"
[loading]="loading"
(onFilterChange)="onSearch($event)">
</ng-multiselect-dropdown>
`
})
export class RemoteDataComponent {
options: any[] = [];
selectedItems = [];
loading = false;
remoteSettings = {
idField: 'id',
textField: 'name',
allowSearchFilter: true,
allowRemoteDataSearch: true
};
constructor(private dataService: DataService) {}
onSearch(searchTerm: string) {
if (searchTerm.length >= 2) {
this.loading = true;
this.dataService.search(searchTerm).subscribe(
data => {
this.options = data;
this.loading = false;
},
error => {
this.loading = false;
console.error('Search failed:', error);
}
);
}
}
}Virtual Scrolling for Large Datasets
@Component({
template: `
<ng-multiselect-dropdown
[data]="largeDataset"
[(ngModel)]="selectedItems"
[settings]="virtualScrollSettings">
</ng-multiselect-dropdown>
`
})
export class VirtualScrollComponent {
largeDataset: any[] = [];
selectedItems = [];
virtualScrollSettings = {
idField: 'id',
textField: 'name',
allowSearchFilter: true,
maxHeight: 300,
lazyLoading: true,
itemHeight: 35 // Height of each option item
};
ngOnInit() {
// Generate large dataset
for (let i = 1; i <= 10000; i++) {
this.largeDataset.push({
id: i,
name: `Item ${i}`,
description: `Description for item ${i}`
});
}
}
}Custom Validation
@Component({
template: `
<form [formGroup]="form">
<ng-multiselect-dropdown
formControlName="skills"
[data]="skillsList"
[settings]="settings">
</ng-multiselect-dropdown>
<div class="validation-messages">
<div *ngIf="form.get('skills')?.hasError('required')" class="text-danger">
Please select at least one skill
</div>
<div *ngIf="form.get('skills')?.hasError('maxSelected')" class="text-danger">
You can select maximum {{ maxSkills }} skills
</div>
<div *ngIf="form.get('skills')?.hasError('requiredSkill')" class="text-danger">
JavaScript is a required skill
</div>
</div>
</form>
`
})
export class ValidationComponent {
maxSkills = 5;
form = this.fb.group({
skills: [[], [
Validators.required,
this.maxSelectedValidator(this.maxSkills),
this.requiredSkillValidator(['JavaScript'])
]]
});
skillsList = [
{ id: 1, name: 'JavaScript' },
{ id: 2, name: 'TypeScript' },
{ id: 3, name: 'Angular' },
{ id: 4, name: 'React' },
{ id: 5, name: 'Vue.js' },
{ id: 6, name: 'Node.js' }
];
settings = {
idField: 'id',
textField: 'name',
limitSelection: this.maxSkills,
allowSearchFilter: true
};
constructor(private fb: FormBuilder) {}
maxSelectedValidator(max: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value && control.value.length > max) {
return { maxSelected: { max, actual: control.value.length } };
}
return null;
};
}
requiredSkillValidator(requiredSkills: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value && control.value.length > 0) {
const selectedNames = control.value.map((item: any) => item.name);
const hasRequired = requiredSkills.some(skill => selectedNames.includes(skill));
return hasRequired ? null : { requiredSkill: { required: requiredSkills } };
}
return null;
};
}
}🎨 Styling & Theming
CSS Classes
// Custom styling
.multiselect-dropdown {
.dropdown-btn {
border: 2px solid #007bff;
border-radius: 8px;
&:focus {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
}
.dropdown-list {
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
ul {
max-height: 250px;
li {
padding: 8px 12px;
&:hover {
background-color: #f8f9fa;
}
&.selected {
background-color: #e3f2fd;
color: #1976d2;
}
}
}
}
.selected-item {
background-color: #007bff;
color: white;
border-radius: 16px;
padding: 4px 8px;
margin: 2px;
font-size: 0.875rem;
.remove-selected {
margin-left: 4px;
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}
}Theming with CSS Custom Properties
.multiselect-dropdown {
--primary-color: #6f42c1;
--primary-light: #e7d9f7;
--border-color: #ced4da;
--text-color: #495057;
--background-color: #fff;
--hover-color: #f8f9fa;
--focus-shadow: rgba(111, 66, 193, 0.25);
.dropdown-btn {
border-color: var(--border-color);
background-color: var(--background-color);
color: var(--text-color);
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem var(--focus-shadow);
}
}
.selected-item {
background-color: var(--primary-color);
}
}
// Dark theme
.dark-theme .multiselect-dropdown {
--primary-color: #bb86fc;
--primary-light: #3c3c54;
--border-color: #6c757d;
--text-color: #e9ecef;
--background-color: #343a40;
--hover-color: #495057;
--focus-shadow: rgba(187, 134, 252, 0.25);
}🔌 Integration Examples
With Angular Material
@Component({
template: `
<mat-form-field appearance="outline">
<mat-label>Select Technologies</mat-label>
<ng-multiselect-dropdown
formControlName="technologies"
[data]="techList"
[settings]="settings">
</ng-multiselect-dropdown>
<mat-error *ngIf="form.get('technologies')?.hasError('required')">
Please select at least one technology
</mat-error>
</mat-form-field>
`
})
export class MaterialIntegrationComponent {
form = this.fb.group({
technologies: [[], Validators.required]
});
techList = [
{ id: 1, name: 'Angular' },
{ id: 2, name: 'React' },
{ id: 3, name: 'Vue.js' }
];
settings = {
idField: 'id',
textField: 'name',
allowSearchFilter: true
};
}With Bootstrap
<div class="form-group">
<label for="categories" class="form-label">Categories</label>
<ng-multiselect-dropdown
id="categories"
class="form-control"
[data]="categories"
[(ngModel)]="selectedCategories"
[settings]="bootstrapSettings">
</ng-multiselect-dropdown>
<div class="form-text">Select one or more categories</div>
</div>🧪 Testing
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MultiselectComponent } from '@ertpl-ui/multi-select';
import { FormsModule } from '@angular/forms';
describe('MultiselectComponent', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [MultiselectComponent, FormsModule]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
});
it('should render dropdown options', () => {
component.data = [
{ id: 1, name: 'Option 1' },
{ id: 2, name: 'Option 2' }
];
fixture.detectChanges();
const dropdown = fixture.nativeElement.querySelector('.dropdown-btn');
dropdown.click();
fixture.detectChanges();
const options = fixture.nativeElement.querySelectorAll('.dropdown-list li');
expect(options.length).toBe(2);
expect(options[0].textContent.trim()).toBe('Option 1');
});
it('should select items', () => {
spyOn(component, 'onItemSelect');
// Simulate item selection
const multiselect = fixture.nativeElement.querySelector('ng-multiselect-dropdown');
multiselect.dispatchEvent(new CustomEvent('onSelect', { detail: { id: 1, name: 'Option 1' } }));
expect(component.onItemSelect).toHaveBeenCalled();
});
});
@Component({
template: `
<ng-multiselect-dropdown
[data]="data"
[(ngModel)]="selectedItems"
[settings]="settings"
(onSelect)="onItemSelect($event)">
</ng-multiselect-dropdown>
`
})
class TestComponent {
data = [];
selectedItems = [];
settings = { idField: 'id', textField: 'name' };
onItemSelect(item: any) {
console.log('Selected:', item);
}
}🚀 Performance Tips
- Use trackBy functions for better change detection:
trackByFn = (index: number, item: any) => item.id;- Enable lazy loading for large datasets:
settings = {
lazyLoading: true,
itemHeight: 35
};- Limit search results:
onSearch(term: string) {
if (term.length >= 2) {
this.filteredData = this.allData
.filter(item => item.name.toLowerCase().includes(term.toLowerCase()))
.slice(0, 100); // Limit to 100 results
}
}🤝 Contributing
- Fork the repository
- Create your feature branch:
git checkout -b feature/my-feature - Commit your changes:
git commit -am 'Add my feature' - Push to the branch:
git push origin feature/my-feature - Submit a pull request
📄 License
MIT © VUI
🔗 Related Packages
ng-vui-select-input- Single select dropdownng-vui-grid- Data grid componentng-vui-date-picker- Date picker componentng-vui-text-input- Text input componentng-vui-textarea- Textarea componentng-vui-auto-complete- Auto complete component
