From 1ce186c68cf7e49ae5ac69519ae3c0738f47cc44 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Tue, 7 Oct 2025 16:55:51 +0100 Subject: [PATCH] feat: add maxItems option to limit multi-select HSSelect Add maxItems option to limit selections in multi-select mode. When limit is reached, dropdown filters to show only selected items. - Works with tags mode and default multi-select mode - Integrates seamlessly with search functionality - Compatible with remote API data sources - Non-breaking opt-in feature Usage: new HSSelect(el, { maxItems: 3 }) --- package.json | 20 +++-- src/js/plugins/select/index.ts | 125 ++++++++++++++++++++++++++++ src/js/plugins/select/interfaces.ts | 2 + 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index e1e9fba..c1b21a8 100644 --- a/package.json +++ b/package.json @@ -113,22 +113,24 @@ "@types/dropzone": "^5.7.9", "@types/jquery": "^3.5.32", "@types/lodash": "^4.17.18", - "dts-bundle-generator": "^9.5.1", - "source-map-loader": "^5.0.0", - "terser-webpack-plugin": "^5.3.12", - "ts-loader": "^9.5.2", - "typescript": "^5.8.2", - "webpack-cli": "^6.0.1", "apexcharts": "^5.2.0", "datatables.net": "^2.2.2", + "dts-bundle-generator": "^9.5.1", "lightningcss": "1.30.1", + "nouislider": "^15.8.1", "postcss": "^8.5.3", "postcss-js": "^4.0.1", "postcss-selector-parser": "^7.1.0", + "source-map-loader": "^5.0.0", "tailwindcss": "4.1.1", - "nouislider": "^15.8.1" + "terser-webpack-plugin": "^5.3.12", + "ts-loader": "^9.5.2", + "typescript": "^5.8.2", + "webpack-cli": "^6.0.1" }, "dependencies": { - "@floating-ui/dom": "^1.7.1" - } + "@floating-ui/dom": "^1.7.1", + "bun": "^1.2.23" + }, + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" } diff --git a/src/js/plugins/select/index.ts b/src/js/plugins/select/index.ts index 21c9243..0fb5ec3 100644 --- a/src/js/plugins/select/index.ts +++ b/src/js/plugins/select/index.ts @@ -19,6 +19,7 @@ import HSAccessibilityObserver from '../accessibility-manager' import { POSITIONS } from '../../constants' + class HSSelect extends HSBasePlugin implements ISelect { private accessibilityComponent: IAccessibilityComponent @@ -120,6 +121,8 @@ class HSSelect extends HSBasePlugin implements ISelect { private optionId = 0 + private readonly maxItems: number | null + private onWrapperClickListener: (evt: Event) => void private onToggleClickListener: () => void private onTagsInputFocusListener: () => void @@ -225,6 +228,7 @@ class HSSelect extends HSBasePlugin implements ISelect { this.iconClasses = concatOptions?.iconClasses || null this.isAddTagOnEnter = concatOptions?.isAddTagOnEnter ?? true this.isSelectedOptionOnTop = concatOptions?.isSelectedOptionOnTop ?? false + this.maxItems = concatOptions?.maxItems || null this.animationInProcess = false @@ -736,6 +740,11 @@ class HSSelect extends HSBasePlugin implements ISelect { if (this.dropdownScope === 'window') this.buildFloatingUI() if (this.dropdown && this.apiLoadMore) this.setupInfiniteScroll() + + // Apply initial filtering if max items already reached + if (this.isMaxItemsReached()) { + this.filterOptionsToSelected() + } } private setupInfiniteScroll() { @@ -1119,6 +1128,12 @@ class HSSelect extends HSBasePlugin implements ISelect { } private async remoteSearch(val: string) { + // When max items reached, search only within selected items + if (this.isMaxItemsReached()) { + this.searchWithinSelected(val) + return + } + if (val.length <= this.minSearchLength) { const res = await this.apiRequest('') this.remoteOptions = res @@ -1234,6 +1249,8 @@ class HSSelect extends HSBasePlugin implements ISelect { } private onSelectOption(val: string) { + const wasMaxReached = this.isMaxItemsReached() + this.clearSelections() if (this.isMultiple) { @@ -1249,6 +1266,22 @@ class HSSelect extends HSBasePlugin implements ISelect { this.setNewValue() } + const isMaxReached = this.isMaxItemsReached() + + // If we just reached the max, filter to selected items + if (!wasMaxReached && isMaxReached) { + this.filterOptionsToSelected() + } + + // If we just went below the max (deselected an item), show all options + if (wasMaxReached && !isMaxReached) { + this.showAllOptions() + // Re-apply any active search + if (this.search && this.search.value) { + this.searchOptions(this.search.value) + } + } + this.fireEvent('change', this.value) if (this.mode === 'tags') { @@ -1503,6 +1536,12 @@ class HSSelect extends HSBasePlugin implements ISelect { } private searchOptions(val: string) { + // If max items reached, only search within selected items + if (this.isMaxItemsReached()) { + this.searchWithinSelected(val) + return + } + if (val.length <= this.minSearchLength) { if (this.searchNoResult) { this.searchNoResult.remove() @@ -1565,6 +1604,92 @@ class HSSelect extends HSBasePlugin implements ISelect { if (!hasItems) this.dropdown.append(this.searchNoResult) } + /** + * Check if max items limit has been reached + */ + private isMaxItemsReached(): boolean { + if (!this.maxItems || !this.isMultiple) return false + return Array.isArray(this.value) && this.value.length >= this.maxItems + } + + /** + * Filter dropdown to show only selected items + */ + private filterOptionsToSelected(): void { + if (!this.value || !Array.isArray(this.value)) return + + const options = this.dropdown.querySelectorAll('[data-value]') + options.forEach(el => { + const dataValue = el.getAttribute('data-value') + if (this.value.includes(dataValue)) { + el.classList.remove('hidden') + } else { + el.classList.add('hidden') + } + }) + } + + /** + * Show all options in dropdown + */ + private showAllOptions(): void { + const options = this.dropdown.querySelectorAll('[data-value]') + options.forEach(el => { + el.classList.remove('hidden') + }) + } + + /** + * Search only within selected items + */ + private searchWithinSelected(val: string): void { + if (!this.value || !Array.isArray(this.value)) return + + const options = this.dropdown.querySelectorAll('[data-value]') + let hasItems = false + + options.forEach(el => { + const dataValue = el.getAttribute('data-value') + const isSelected = this.value.includes(dataValue) + + // Hide non-selected items + if (!isSelected) { + el.classList.add('hidden') + return + } + + // For selected items, apply search filter + if (val && val.length > this.minSearchLength) { + const optionVal = el.getAttribute('data-title-value').toLocaleLowerCase() + const matches = optionVal.includes(val.toLowerCase()) + + if (matches) { + el.classList.remove('hidden') + hasItems = true + } else { + el.classList.add('hidden') + } + } else { + // No search term, show all selected items + el.classList.remove('hidden') + hasItems = true + } + }) + + // Handle no results message + if (this.searchNoResult) { + this.searchNoResult.remove() + this.searchNoResult = null + } + + if (val && val.length > this.minSearchLength && !hasItems) { + this.searchNoResult = htmlToElement(this.searchNoResultTemplate) + this.searchNoResult.innerText = this.searchNoResultText + classToClassList(this.searchNoResultClasses, this.searchNoResult) + this.dropdown.append(this.searchNoResult) + } + } + private eraseToggleIcon() { const icon = this.toggle.querySelector('[data-icon]') diff --git a/src/js/plugins/select/interfaces.ts b/src/js/plugins/select/interfaces.ts index dcdd141..ab2d1c4 100644 --- a/src/js/plugins/select/interfaces.ts +++ b/src/js/plugins/select/interfaces.ts @@ -105,6 +105,8 @@ export interface ISelectOptions { dropdownAutoPlacement?: boolean isSelectedOptionOnTop?: boolean + + maxItems?: number } export interface ISelect {