From 0592dcc3bebef82d7fd63777e773542b234b00e5 Mon Sep 17 00:00:00 2001 From: Sebastian Zoglowek <55794780+zoglo@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:21:55 +0200 Subject: [PATCH] Initial logic for the `dropdownParent` feature # Conflicts: # src/scripts/choices.ts --- src/scripts/choices.ts | 107 ++++++++++++++------------ src/scripts/defaults.ts | 2 + src/scripts/interfaces/class-names.ts | 2 + src/scripts/interfaces/options.ts | 2 + src/styles/choices.scss | 4 + 5 files changed, 67 insertions(+), 50 deletions(-) diff --git a/src/scripts/choices.ts b/src/scripts/choices.ts index f15efa2b..80ab8308 100644 --- a/src/scripts/choices.ts +++ b/src/scripts/choices.ts @@ -158,6 +158,10 @@ class Choices { _docRoot: ShadowRoot | HTMLElement; + _dropdownParent: HTMLElement | null; + + _dropdownFixed: boolean; + constructor( element: string | Element | HTMLInputElement | HTMLSelectElement = '[data-choice]', userConfig: Partial = {}, @@ -260,7 +264,7 @@ class Choices { this._store = new Store(config); this._currentValue = ''; - config.searchEnabled = !isText && config.searchEnabled; + config.searchEnabled = (!isText && config.searchEnabled) || isSelectMultiple; this._canSearch = config.searchEnabled; this._isScrollingOnIe = false; this._highlightPosition = 0; @@ -319,6 +323,18 @@ class Choices { return; } + // Position fixed for dropdown items + this._dropdownFixed = false; + + if (config.dropdownParent) { + const parent = this._docRoot.querySelector(config.dropdownParent); + + if (parent) { + this._dropdownFixed = true; + this._dropdownParent = parent; + } + } + // Let's go this.init(); // preserve the selected item list after setup for form reset @@ -513,23 +529,22 @@ class Choices { requestAnimationFrame(() => { this.dropdown.show(); - const rect = this.dropdown.element.getBoundingClientRect(); - this.containerOuter.open(rect.bottom, rect.height); + + if (this._dropdownFixed) { + const containerRect = this.containerOuter.element.getBoundingClientRect(); + this.dropdown.element.style.top = `${containerRect.bottom}px`; + this.dropdown.element.style.left = `${containerRect.left}px`; + this.dropdown.element.style.width = `${containerRect.width}px`; + } + + const dropdownRect = this.dropdown.element.getBoundingClientRect(); + this.containerOuter.open(dropdownRect.bottom, dropdownRect.height); if (!preventInputFocus) { this.input.focus(); } this.passedElement.triggerEvent(EventType.showDropdown); - - const activeElement = this.choiceList.element.querySelector( - getClassNamesSelector(this.config.classNames.selectedState), - ); - - if (activeElement !== null && !isScrolledIntoView(activeElement, this.choiceList.element)) { - // We use the native scrollIntoView function instead of choiceList.scrollToChildElement to avoid animated scroll. - activeElement.scrollIntoView(); - } }); return this; @@ -540,8 +555,6 @@ class Choices { return this; } - this._removeHighlightedChoices(); - requestAnimationFrame(() => { this.dropdown.hide(); this.containerOuter.close(); @@ -970,15 +983,11 @@ class Choices { const renderableChoices = (choices: ChoiceFull[]): ChoiceFull[] => choices.filter( (choice) => - !choice.placeholder && - (isSearching - ? (config.searchRenderSelectedChoices || !choice.selected) && !!choice.rank - : config.renderSelectedChoices || !choice.selected), + !choice.placeholder && (isSearching ? !!choice.rank : config.renderSelectedChoices || !choice.selected), ); const showLabel = config.appendGroupInSearch && isSearching; let selectableChoices = false; - let highlightedEl: HTMLElement | null = null; const renderChoices = (choices: ChoiceFull[], withinGroup: boolean): void => { if (isSearching) { // sortByRank is used to ensure stable sorting, as scores are non-unique @@ -1006,8 +1015,6 @@ class Choices { fragment.appendChild(dropdownItem); if (isSearching || !choice.selected) { selectableChoices = true; - } else if (!highlightedEl) { - highlightedEl = dropdownItem; } return index < choiceLimit; @@ -1069,7 +1076,9 @@ class Choices { this._renderNotice(fragment); this.choiceList.element.replaceChildren(fragment); - this._highlightChoice(highlightedEl); + if (selectableChoices) { + this._highlightChoice(); + } } _renderItems(): void { @@ -2054,10 +2063,14 @@ class Choices { this.containerOuter.addInvalidState(); } - /** - * Removes any highlighted choice options - */ - _removeHighlightedChoices(): void { + _highlightChoice(el: HTMLElement | null = null): void { + const choices = Array.from(this.dropdown.element.querySelectorAll(selectableChoiceIdentifier)); + + if (!choices.length) { + return; + } + + let passedEl = el; const { highlightedState } = this.config.classNames; const highlightedChoices = Array.from( this.dropdown.element.querySelectorAll(getClassNamesSelector(highlightedState)), @@ -2068,19 +2081,6 @@ class Choices { removeClassesFromElement(choice, highlightedState); choice.setAttribute('aria-selected', 'false'); }); - } - - _highlightChoice(el: HTMLElement | null = null): void { - const choices = Array.from(this.dropdown.element.querySelectorAll(selectableChoiceIdentifier)); - - if (!choices.length) { - return; - } - - let passedEl = el; - const { highlightedState } = this.config.classNames; - - this._removeHighlightedChoices(); if (passedEl) { this._highlightPosition = choices.indexOf(passedEl); @@ -2280,6 +2280,7 @@ class Choices { _createStructure(): void { const { containerInner, containerOuter, passedElement } = this; const dropdownElement = this.dropdown.element; + let dropdownParent: HTMLElement = containerOuter.element; // Hide original element passedElement.conceal(); @@ -2288,26 +2289,32 @@ class Choices { // Wrapper inner container with outer container containerOuter.wrap(containerInner.element); - containerOuter.element.appendChild(containerInner.element); - containerOuter.element.appendChild(dropdownElement); - containerInner.element.appendChild(this.itemList.element); - dropdownElement.appendChild(this.choiceList.element); - if (this._isSelectOneElement) { this.input.placeholder = this.config.searchPlaceholderValue || ''; - if (this.config.searchEnabled) { - dropdownElement.insertBefore(this.input.element, dropdownElement.firstChild); - } } else { - if (!this._isSelectMultipleElement || this.config.searchEnabled) { - containerInner.element.appendChild(this.input.element); - } if (this._placeholderValue) { this.input.placeholder = this._placeholderValue; } this.input.setWidth(); } + if (this._dropdownFixed && this._dropdownParent instanceof HTMLElement) { + const { fixed } = this.config.classNames; + dropdownParent = this._dropdownParent; + addClassesToElement(dropdownElement, fixed); + } + + containerOuter.element.appendChild(containerInner.element); + dropdownParent.appendChild(dropdownElement); + containerInner.element.appendChild(this.itemList.element); + dropdownElement.appendChild(this.choiceList.element); + + if (!this._isSelectOneElement) { + containerInner.element.appendChild(this.input.element); + } else if (this.config.searchEnabled) { + dropdownElement.insertBefore(this.input.element, dropdownElement.firstChild); + } + this._highlightPosition = 0; this._isSearching = false; } diff --git a/src/scripts/defaults.ts b/src/scripts/defaults.ts index c360f4ef..8ecc2829 100644 --- a/src/scripts/defaults.ts +++ b/src/scripts/defaults.ts @@ -18,6 +18,7 @@ export const DEFAULT_CLASSNAMES: ClassNames = { itemChoice: ['choices__item--choice'], description: ['choices__description'], placeholder: ['choices__placeholder'], + fixed: ['choices__position--fixed'], group: ['choices__group'], groupHeading: ['choices__heading'], button: ['choices__button'], @@ -95,4 +96,5 @@ export const DEFAULT_CONFIG: Options = { callbackOnCreateTemplates: null, classNames: DEFAULT_CLASSNAMES, appendGroupInSearch: false, + dropdownParent: null, } as const; diff --git a/src/scripts/interfaces/class-names.ts b/src/scripts/interfaces/class-names.ts index 3fda36cc..e98c0c0a 100644 --- a/src/scripts/interfaces/class-names.ts +++ b/src/scripts/interfaces/class-names.ts @@ -28,6 +28,8 @@ export interface ClassNames { description: string | Array; /** @default ['choices__placeholder'] */ placeholder: string | Array; + /** @default ['choices__position-fixed'] */ + fixed: string | Array; /** @default ['choices__group'] */ group: string | Array; /** @default ['choices__heading'] */ diff --git a/src/scripts/interfaces/options.ts b/src/scripts/interfaces/options.ts index 55e02203..53a58c94 100644 --- a/src/scripts/interfaces/options.ts +++ b/src/scripts/interfaces/options.ts @@ -634,4 +634,6 @@ export interface Options { callbackOnCreateTemplates: CallbackOnCreateTemplatesFn | null; appendGroupInSearch: boolean; + + dropdownParent: string | null; } diff --git a/src/styles/choices.scss b/src/styles/choices.scss index 9b02e5e4..888f774a 100644 --- a/src/styles/choices.scss +++ b/src/styles/choices.scss @@ -272,6 +272,10 @@ $choices-placeholder-opacity: 0.5 !default; } } +.#{$choices-selector}__position--fixed { + position: fixed !important; +} + %choices-dropdown { display: none; z-index: var(--choices-z-index, #{$choices-z-index});