From 63f894f5269a48532783f9b914aeed772f8457d3 Mon Sep 17 00:00:00 2001 From: STEINNI Date: Fri, 5 Jun 2026 15:16:01 +0000 Subject: [PATCH] portal trick on bz-select options --- buildoz.css | 18 ++++++-- buildoz.js | 124 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 124 insertions(+), 18 deletions(-) diff --git a/buildoz.css b/buildoz.css index 90db086..798db8f 100644 --- a/buildoz.css +++ b/buildoz.css @@ -41,7 +41,17 @@ bz-select > div.options-container{ transition: max-height 0.4s ease; } bz-select > div.options-container.open{ pointer-events: auto; max-height: 10em;} -bz-select option{ +div.options-container.portaled{ + pointer-events: none; + position: fixed; + z-index: 10000; + max-height: 0; + overflow: auto; + transition: max-height 0.4s ease; +} +div.options-container.portaled.open{ pointer-events: auto; max-height: 10em;} +bz-select option, +div.options-container option{ background-color: #DDD; border: 1px solid black; color: #000; @@ -57,12 +67,14 @@ bz-select option{ margin-top 0.3s ease, opacity 0.3s ease; } -bz-select option.open{ +bz-select option.open, +div.options-container option.open{ margin: 0; opacity: 1; pointer-events: auto; } -bz-select option:hover{ +bz-select option:hover, +div.options-container option:hover{ background-color: #44F; color: #FFF; } diff --git a/buildoz.js b/buildoz.js index 5718f0d..d3b66e8 100644 --- a/buildoz.js +++ b/buildoz.js @@ -74,7 +74,7 @@ class BZselect extends Buildoz { if(!this.optionscontainer) this.optionscontainer = document.createElement('div') this.optionscontainer.classList.add('options-container') this.append(this.optionscontainer) - this.options = this.querySelectorAll('option') + this.syncOptions() if(this.#fillFromMarkup){ //can only do it once and only if fillOptions was not already called !! for(const opt of this.options){ this.optionscontainer.append(opt) // Will move is to the right parent @@ -85,6 +85,15 @@ class BZselect extends Buildoz { } } + + disconnectedCallback() { + if(this.open || this._closing) this.closeDropdown(true) + } + + syncOptions() { + if(this.optionscontainer) this.options = this.optionscontainer.querySelectorAll('option') + else this.options = [] + } // static get observedAttributes(){ // Only if you want actions on attr change // return([...super.observedAttributes, 'disabled']) @@ -111,19 +120,104 @@ class BZselect extends Buildoz { } toggle(){ - for(const opt of this.options){ - if(this.open) { - opt.classList.remove('open') - this.optionscontainer.classList.remove('open') - } else { - document.querySelectorAll('bz-select').forEach((sel) => { - if((sel!==this) && sel.open) sel.toggle() - }) - opt.classList.add('open') + if(this.open) this.closeDropdown() + else this.openDropdown() + } + + openDropdown() { + document.querySelectorAll('bz-select').forEach((sel) => { + if((sel!==this) && sel.open) sel.closeDropdown(true) + }) + this.optionscontainer.classList.add('portaled') + document.body.appendChild(this.optionscontainer) + this.positionPortal() + this.bindPortalListeners() + this.open = true + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if(!this.open) return this.optionscontainer.classList.add('open') + for(const opt of this.options) opt.classList.add('open') + }) + }) + } + + closeDropdown(immediate = false) { + if(!this.open) return + this.unbindPortalListeners() + for(const opt of this.options) opt.classList.remove('open') + this.optionscontainer.classList.remove('open') + if(immediate) { + this.clearCloseTransition() + this.finishCloseDropdown() + return + } + this._closing = true + this._onTransitionEnd = (evt) => { + if(evt.target !== this.optionscontainer) return + if(evt.propertyName !== 'max-height') return + this.finishCloseDropdownAfterTransition() + } + this.optionscontainer.addEventListener('transitionend', this._onTransitionEnd) + this._closeTimer = setTimeout(() => this.finishCloseDropdownAfterTransition(), 450) + } + + finishCloseDropdownAfterTransition() { + if(!this._closing) return + this.clearCloseTransition() + this.finishCloseDropdown() + } + + clearCloseTransition() { + this._closing = false + if(this._closeTimer) clearTimeout(this._closeTimer) + this._closeTimer = null + if(this._onTransitionEnd) { + this.optionscontainer.removeEventListener('transitionend', this._onTransitionEnd) + this._onTransitionEnd = null + } + } + + finishCloseDropdown() { + this.optionscontainer.classList.remove('portaled') + this.clearPortalStyles() + if(this.optionscontainer.parentElement !== this) this.append(this.optionscontainer) + this.open = false + } + + positionPortal() { + const rect = this.button.getBoundingClientRect() + this.optionscontainer.style.top = `${rect.bottom}px` + this.optionscontainer.style.left = `${rect.left}px` + this.optionscontainer.style.width = `${rect.width}px` + } + + clearPortalStyles() { + this.optionscontainer.style.top = '' + this.optionscontainer.style.left = '' + this.optionscontainer.style.width = '' + } + + bindPortalListeners() { + this._repositionBound = this.positionPortal.bind(this) + window.addEventListener('scroll', this._repositionBound, true) + window.addEventListener('resize', this._repositionBound) + this._outsideClickBound = (evt) => { + if(this.open && !this.contains(evt.target) && !this.optionscontainer.contains(evt.target)) { + this.closeDropdown() } } - this.open = !this.open + this._outsideClickTimer = setTimeout(() => { + document.addEventListener('click', this._outsideClickBound, true) + }, 0) + } + + unbindPortalListeners() { + if(this._repositionBound) window.removeEventListener('scroll', this._repositionBound, true) + if(this._repositionBound) window.removeEventListener('resize', this._repositionBound) + if(this._outsideClickBound) document.removeEventListener('click', this._outsideClickBound, true) + if(this._outsideClickTimer) clearTimeout(this._outsideClickTimer) + this._outsideClickTimer = null } onClick(evt){ @@ -135,7 +229,7 @@ class BZselect extends Buildoz { onOption(value, silent=false){ if(this.getAttribute('disabled') !== null) return this.value = value - if(!silent) this.toggle() + if(!silent && this.open) this.closeDropdown() } addOption(value, markup){ @@ -146,16 +240,16 @@ class BZselect extends Buildoz { opt.addEventListener('click',this.onClick.bind(this)) if(!this.optionscontainer) this.optionscontainer = document.createElement('div') this.optionscontainer.append(opt) - this.options = this.querySelectorAll('option') + this.syncOptions() this.#fillFromMarkup = false } fillOptions(opts, erase = true){ // Caution: you cannot count on connectedCallback to have run already, because one might fill before adding to the DOM if(erase){ - this.options = this.querySelectorAll('option') + this.syncOptions() this.options.forEach(node => { node.remove() }) - this.options = this.querySelectorAll('option') + this.syncOptions() this.onOption('', true) // unselect last } for(const opt of opts) this.addOption(opt.value, opt.markup)