Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
125 changes: 125 additions & 0 deletions src/js/plugins/select/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import HSAccessibilityObserver from '../accessibility-manager'

import { POSITIONS } from '../../constants'


class HSSelect extends HSBasePlugin<ISelectOptions> implements ISelect {
private accessibilityComponent: IAccessibilityComponent

Expand Down Expand Up @@ -120,6 +121,8 @@ class HSSelect extends HSBasePlugin<ISelectOptions> implements ISelect {

private optionId = 0

private readonly maxItems: number | null

private onWrapperClickListener: (evt: Event) => void
private onToggleClickListener: () => void
private onTagsInputFocusListener: () => void
Expand Down Expand Up @@ -225,6 +228,7 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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

Expand Down Expand Up @@ -736,6 +740,11 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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() {
Expand Down Expand Up @@ -1119,6 +1128,12 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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
Expand Down Expand Up @@ -1234,6 +1249,8 @@ class HSSelect extends HSBasePlugin<ISelectOptions> implements ISelect {
}

private onSelectOption(val: string) {
const wasMaxReached = this.isMaxItemsReached()

this.clearSelections()

if (this.isMultiple) {
Expand All @@ -1249,6 +1266,22 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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') {
Expand Down Expand Up @@ -1503,6 +1536,12 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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()
Expand Down Expand Up @@ -1565,6 +1604,92 @@ class HSSelect extends HSBasePlugin<ISelectOptions> 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]')

Expand Down
2 changes: 2 additions & 0 deletions src/js/plugins/select/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export interface ISelectOptions {
dropdownAutoPlacement?: boolean

isSelectedOptionOnTop?: boolean

maxItems?: number
}

export interface ISelect {
Expand Down