[UI] Dropdown component rewrite.

Dropdown components should always be rendered under the root element, or
nasty effects caused by absolute parenting may end up hiding dropdown
elements regardless of their `z-index`.

The new approach uses a single `<DropdownContainer>` element in the
main `App` file. Each `<Dropdown>` component will push updates to the
bus whenever it triggers open/close events, and the dropdown component
to be rendered will be pushed upstream and rendered in the root element.
This commit is contained in:
Fabio Manganiello 2023-11-08 20:54:04 +01:00
parent fafc1747d6
commit f7a25a478d
Signed by: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 182 additions and 67 deletions

View file

@ -8,24 +8,31 @@
Would you like to install this application locally? Would you like to install this application locally?
</ConfirmDialog> </ConfirmDialog>
<DropdownContainer />
<router-view /> <router-view />
</template> </template>
<script> <script>
import ConfirmDialog from "@/components/elements/ConfirmDialog"; import ConfirmDialog from "@/components/elements/ConfirmDialog";
import DropdownContainer from "@/components/elements/DropdownContainer";
import Notifications from "@/components/Notifications"; import Notifications from "@/components/Notifications";
import Utils from "@/Utils"; import Utils from "@/Utils";
import Events from "@/Events"; import Events from "@/Events";
import VoiceAssistant from "@/components/VoiceAssistant"; import VoiceAssistant from "@/components/VoiceAssistant";
import { bus } from "@/bus";
import Ntfy from "@/components/Ntfy"; import Ntfy from "@/components/Ntfy";
import Pushbullet from "@/components/Pushbullet"; import Pushbullet from "@/components/Pushbullet";
import { bus } from "@/bus";
export default { export default {
name: 'App',
mixins: [Utils], mixins: [Utils],
components: { components: {
ConfirmDialog, Pushbullet, Ntfy, Notifications, Events, VoiceAssistant ConfirmDialog,
DropdownContainer,
Events,
Notifications,
Ntfy,
Pushbullet,
VoiceAssistant,
}, },
data() { data() {
@ -104,7 +111,6 @@ export default {
} }
</script> </script>
<!--suppress CssUnusedSymbol -->
<style lang="scss"> <style lang="scss">
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts"; $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome"; @import "~@fortawesome/fontawesome-free/scss/fontawesome";

View file

@ -1,30 +1,30 @@
<template> <template>
<div class="dropdown-container" ref="container"> <div class="dropdown-container">
<button :title="title" ref="button" @click.stop="toggle($event)"> <button :title="title" ref="button" @click.stop="toggle($event)">
<i class="icon" :class="iconClass" v-if="iconClass" /> <i class="icon" :class="iconClass" v-if="iconClass" />
<span class="text" v-text="text" v-if="text" /> <span class="text" v-text="text" v-if="text" />
</button> </button>
<div class="dropdown fade-in" :id="id" :class="{hidden: !visible}" ref="dropdown"> <div class="body-container hidden" ref="dropdownContainer">
<slot /> <DropdownBody :id="id" :keepOpenOnItemClick="keepOpenOnItemClick" ref="dropdown">
<slot />
</DropdownBody>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import DropdownBody from "./DropdownBody";
import { bus } from "@/bus";
export default { export default {
name: "Dropdown", components: { DropdownBody },
emits: ['click'], emits: ['click'],
props: { props: {
id: { id: {
type: String, type: String,
}, },
items: {
type: Array,
default: () => [],
},
iconClass: { iconClass: {
default: 'fa fa-ellipsis-h', default: 'fa fa-ellipsis-h',
}, },
@ -49,6 +49,39 @@ export default {
} }
}, },
computed: {
dropdownWidth() {
const dropdown = this.$refs.dropdown?.$el
if (!dropdown)
return 0
return parseFloat(getComputedStyle(dropdown).width)
},
dropdownHeight() {
const dropdown = this.$refs.dropdown?.$el
if (!dropdown)
return 0
return parseFloat(getComputedStyle(dropdown).height)
},
buttonStyle() {
if (!this.$refs.button)
return {}
return getComputedStyle(this.$refs.button)
},
buttonWidth() {
return parseFloat(this.buttonStyle.width || 0)
},
buttonHeight() {
return parseFloat(this.buttonStyle.height || 0)
},
},
methods: { methods: {
documentClickHndl(event) { documentClickHndl(event) {
if (!this.visible) if (!this.visible)
@ -56,9 +89,7 @@ export default {
let element = event.target let element = event.target
while (element) { while (element) {
if (!this.$refs.dropdown) if (element.classList.contains('dropdown'))
break
if (element === this.$refs.dropdown.element)
return return
element = element.parentElement element = element.parentElement
@ -70,23 +101,40 @@ export default {
close() { close() {
this.visible = false this.visible = false
document.removeEventListener('click', this.documentClickHndl) document.removeEventListener('click', this.documentClickHndl)
bus.emit('dropdown-close')
}, },
open() { open() {
document.addEventListener('click', this.documentClickHndl) document.addEventListener('click', this.documentClickHndl)
this.visible = true this.visible = true
this.$refs.dropdownContainer.classList.remove('hidden')
this.$nextTick(() => {
const buttonRect = this.$refs.button.getBoundingClientRect()
const buttonPos = {
left: buttonRect.left + window.scrollX,
top: buttonRect.top + window.scrollY,
}
setTimeout(() => { const pos = {
const element = this.$refs.dropdown left: buttonPos.left,
element.style.left = 0 top: buttonPos.top + this.buttonHeight,
element.style.top = parseFloat(getComputedStyle(this.$refs.button).height) + 'px' }
if (element.getBoundingClientRect().left > window.innerWidth/2) if ((pos.left + this.dropdownWidth) > (window.innerWidth + window.scrollX) / 2) {
element.style.left = (-element.clientWidth + parseFloat(getComputedStyle(this.$refs.button).width)) + 'px' pos.left -= (this.dropdownWidth - this.buttonWidth)
}
if (element.getBoundingClientRect().top > window.innerHeight/2) if ((pos.top + this.dropdownHeight) > (window.innerHeight + window.scrollY) / 2) {
element.style.top = (-element.clientHeight + parseFloat(getComputedStyle(this.$refs.button).height)) + 'px' pos.top -= (this.dropdownHeight + this.buttonHeight - 10)
}, 10) }
const element = this.$refs.dropdown.$el
element.classList.add('fade-in')
element.style.top = `${pos.top}px`
element.style.left = `${pos.left}px`
bus.emit('dropdown-open', this.$refs.dropdown)
this.$refs.dropdownContainer.classList.add('hidden')
})
}, },
toggle(event) { toggle(event) {
@ -128,40 +176,5 @@ export default {
color: $default-hover-fg; color: $default-hover-fg;
} }
} }
.dropdown {
position: absolute;
width: max-content;
background: $dropdown-bg;
border-radius: .25em;
border: $default-border-3;
box-shadow: $dropdown-shadow;
display: flex;
flex-direction: column;
z-index: 1;
}
} }
:deep(.dropdown-container) {
button {
width: 100%;
height: 100%;
color: $default-fg-2;
background: $dropdown-bg;
border: 0;
padding: 0.75em 0.5em;
text-align: left;
letter-spacing: 0.01em;
&:hover {
background: $hover-bg;
color: $default-fg-2;
}
.text {
padding-left: 0.25em;
}
}
}
</style> </style>

View file

@ -0,0 +1,56 @@
<template>
<div class="dropdown" :id="id">
<slot />
</div>
</template>
<script>
export default {
emits: ['click'],
props: {
id: {
type: String,
},
keepOpenOnItemClick: {
type: Boolean,
default: false,
},
},
}
</script>
<style lang="scss" scoped>
.dropdown {
position: absolute;
width: max-content;
background: $dropdown-bg;
border-radius: .25em;
box-shadow: $dropdown-shadow;
display: flex;
flex-direction: column;
z-index: 2;
}
:deep(.dropdown-container) {
button {
width: 100%;
height: 100%;
color: $default-fg-2;
background: $dropdown-bg;
border: 0;
padding: 0.75em 0.5em;
text-align: left;
letter-spacing: 0.01em;
&:hover {
background: $hover-bg;
color: $default-fg-2;
}
.text {
padding-left: 0.25em;
}
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="dropdown-container" />
</template>
<script>
import { bus } from "@/bus";
export default {
methods: {
onOpen(component) {
if (!component?.$el)
return
if (!component.keepOpenOnItemClick)
this.onClose()
this.$el.appendChild(component.$el)
},
onClose() {
this.$el.innerHTML = ''
},
},
mounted() {
bus.on('dropdown-open', this.onOpen)
bus.on('dropdown-close', this.onClose)
},
}
</script>
<style lang="scss" scoped>
.dropdown-container {
:deep(.dropdown) {
border: $default-border-2;
}
}
</style>

View file

@ -9,9 +9,9 @@
<script> <script>
import Icon from "@/components/elements/Icon"; import Icon from "@/components/elements/Icon";
import { bus } from "@/bus";
export default { export default {
name: "DropdownItem",
components: {Icon}, components: {Icon},
props: { props: {
iconClass: { iconClass: {
@ -35,13 +35,12 @@ export default {
}, },
methods: { methods: {
clicked(event) { clicked() {
if (this.disabled) if (this.disabled)
return false return false
this.$parent.$emit('click', event)
if (!this.$parent.keepOpenOnItemClick) if (!this.$parent.keepOpenOnItemClick)
this.$parent.visible = false bus.emit('dropdown-close')
} }
} }
} }
@ -50,20 +49,22 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.item { .item {
display: flex; display: flex;
flex-direction: row !important;
min-width: 7.5em; min-width: 7.5em;
padding: 0.75em 0.5em; padding: 0.75em 0.5em;
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
color: $default-fg-2; color: $default-fg-2;
border: 0 !important; border: 0 !important;
box-shadow: none; cursor: pointer !important;
box-shadow: none !important;
&:hover { &:hover {
background: $hover-bg; background: $hover-bg !important;
} }
&.selected { &.selected {
font-weight: bold; font-weight: bold !important;
} }
&.disabled { &.disabled {

View file

@ -60,6 +60,7 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
$border-shadow-left: -2.5px 0 4px 0 $default-shadow-color; $border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
$border-shadow-right: 2.5px 0 4px 0 $default-shadow-color; $border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color; $border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
$border-shadow: 0 0 3px 3px #c0c0c0 !default;
$header-shadow: 0px 1px 3px 1px #bbb !default; $header-shadow: 0px 1px 3px 1px #bbb !default;
$group-shadow: 3px -2px 6px 1px #98b0a0; $group-shadow: 3px -2px 6px 1px #98b0a0;
$primary-btn-shadow: 1px 1px 0.5px 0.75px #32b64640 !default; $primary-btn-shadow: 1px 1px 0.5px 0.75px #32b64640 !default;