portal trick on bz-select options

This commit is contained in:
STEINNI
2026-06-05 15:16:01 +00:00
parent 7f4e13c5e0
commit 63f894f526
2 changed files with 124 additions and 18 deletions
+15 -3
View File
@@ -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;
}
+109 -15
View File
@@ -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
@@ -86,6 +86,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)