forked from platypush/platypush
[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:
parent
fafc1747d6
commit
f7a25a478d
6 changed files with 182 additions and 67 deletions
|
@ -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";
|
||||||
|
|
|
@ -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">
|
||||||
|
<DropdownBody :id="id" :keepOpenOnItemClick="keepOpenOnItemClick" ref="dropdown">
|
||||||
<slot />
|
<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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue