npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ng-vui/multi-select

v1.0.0

Published

Angular Multi-Select Dropdown Component for VUI Library

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

  1. Use trackBy functions for better change detection:
trackByFn = (index: number, item: any) => item.id;
  1. Enable lazy loading for large datasets:
settings = {
  lazyLoading: true,
  itemHeight: 35
};
  1. 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

  1. Fork the repository
  2. Create your feature branch: git checkout -b feature/my-feature
  3. Commit your changes: git commit -am 'Add my feature'
  4. Push to the branch: git push origin feature/my-feature
  5. Submit a pull request

📄 License

MIT © VUI

🔗 Related Packages

  • ng-vui-select-input - Single select dropdown
  • ng-vui-grid - Data grid component
  • ng-vui-date-picker - Date picker component
  • ng-vui-text-input - Text input component
  • ng-vui-textarea - Textarea component
  • ng-vui-auto-complete - Auto complete component