store for lists; output correct lists; start contextmenui
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="app\" />
|
||||
<Folder Include="src\utils\" />
|
||||
<Folder Include="src\views\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+82
-30
@@ -1,47 +1,99 @@
|
||||
<template>
|
||||
<aside class="menu">
|
||||
<img class="logo" src="/just.svg" :alt="$t('app.name')" />
|
||||
<nav class="navigation">
|
||||
<navigation-item name="Shopping" symbol="ListTodo" :active="true" :count="7" />
|
||||
<navigation-item name="Work" symbol="ListTodo" :count="702" />
|
||||
<navigation-item name="Apartment" symbol="ListTodo" />
|
||||
<navigation-item name="Add a list" symbol="ListPlus" :minor="true" />
|
||||
</nav>
|
||||
<div class="menu-bottom">
|
||||
<navigation-item name="Settings" symbol="Bolt" :minor="true" />
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app">
|
||||
<app-menu :open="menuOpen" v-click-outside="closeMenu" />
|
||||
<main class="main">
|
||||
<header class="top">
|
||||
<img class="top-logo" src="/just.svg" :alt="$t('app.name')" />
|
||||
<button type="button" class="top-menu-toggle" @click.stop="menuOpen=!menuOpen">
|
||||
<ui-icon symbol="Menu" :size="18" :stroke-width="2" />
|
||||
</button>
|
||||
<button type="button" class="top-list">
|
||||
Shopping
|
||||
<ui-icon symbol="ChevronDown" :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<!-- <button type="button" @click="preload">Preload</button>-->
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NavigationItem from '@/components/navigation-item.vue';
|
||||
import { posts } from '@/db';
|
||||
import { ref } from 'vue'
|
||||
import AppMenu from '@/modules/app-menu.vue'
|
||||
import { useResize } from '@/utils/composables'
|
||||
|
||||
let items = posts.find().fetch();
|
||||
console.info(items);
|
||||
let menuOpen = ref(false);
|
||||
|
||||
function closeMenu()
|
||||
{
|
||||
menuOpen.value = false;
|
||||
}
|
||||
|
||||
let resizeTimeout: number | undefined = undefined;
|
||||
useResize(() =>
|
||||
{
|
||||
document.body.classList.add('is-resizing');
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() =>
|
||||
{
|
||||
document.body.classList.remove('is-resizing');
|
||||
}, 300);
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu
|
||||
.main
|
||||
{
|
||||
width: 260px;
|
||||
margin: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-menu-bg);
|
||||
height: calc(100vh - 20px);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
padding: var(--space-m) 0;
|
||||
|
||||
@media (max-width: 700px)
|
||||
{
|
||||
padding: var(--space-m);
|
||||
}
|
||||
}
|
||||
|
||||
.logo
|
||||
.top
|
||||
{
|
||||
width: 30px;
|
||||
color: var(--color-text-dim);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.navigation
|
||||
.top-logo
|
||||
{
|
||||
margin-top: 12px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.top-menu-toggle
|
||||
{
|
||||
color: var(--color-text-dim);
|
||||
width: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover
|
||||
{
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.top-logo, .top-menu-toggle
|
||||
{
|
||||
@media (min-width: 701px)
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.top-list
|
||||
{
|
||||
color: var(--color-text-dim);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<button v-if="!confirming" :disabled="disabled" type="button" @click.stop="onClick" class="ui-dropdown-button" :class="{ 'has-icon': icon, 'is-active': selected, 'is-multiline': multiline }">
|
||||
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
|
||||
<span class="-name"><ui-localize :value="label" /><span v-if="confirm && !confirming"> …</span></span>
|
||||
<ui-icon v-if="selected" symbol="fth-check" class="ui-dropdown-button-selected" />
|
||||
<i v-if="loading" class="ui-dropdown-button-progress"></i>
|
||||
</button>
|
||||
<div v-if="confirming" class="ui-dropdown-button ui-dropdown-button-confirmation">
|
||||
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
|
||||
<span class="-name"><ui-localize :value="label" /><span>?</span></span>
|
||||
<ui-button type="small light" icon="fth-x" title="@ui.cancel" @click.stop="confirming=false" />
|
||||
<ui-button :type="negative ? 'small danger' : 'small primary'" icon="fth-check" title="@ui.ok" @click.stop="onClick($event, true)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'uiDropdownButton',
|
||||
|
||||
props: {
|
||||
value: {
|
||||
default: null
|
||||
},
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
confirm: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
negative: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
prevent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
dropdown: null,
|
||||
loading: false,
|
||||
confirming: false
|
||||
}),
|
||||
|
||||
mounted ()
|
||||
{
|
||||
// find parent dropdown so we can pass it on item click
|
||||
let current = this;
|
||||
do
|
||||
{
|
||||
if (current.$options.name === 'uiDropdown')
|
||||
{
|
||||
this.dropdown = current;
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (current = current.$parent);
|
||||
|
||||
if (!this.dropdown)
|
||||
{
|
||||
console.warn('ui-dropdown-button: Could not find parent <ui-dropdown />');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
onClick(e, confirmed)
|
||||
{
|
||||
var instance = this;
|
||||
|
||||
if (!this.loading && !this.disabled)
|
||||
{
|
||||
if (this.confirm && !confirmed)
|
||||
{
|
||||
this.confirming = true;
|
||||
//var confirmed = window.confirm('confirm?');
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.confirming = false;
|
||||
}
|
||||
|
||||
if (!this.prevent && this.dropdown)
|
||||
{
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
this.$emit('click', this.value, {
|
||||
dropdown: this.dropdown,
|
||||
hide()
|
||||
{
|
||||
if (self.dropdown)
|
||||
{
|
||||
self.dropdown.hide();
|
||||
}
|
||||
instance.$emit('hide');
|
||||
},
|
||||
loading(isLoading)
|
||||
{
|
||||
instance.loading = isLoading;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
button.ui-dropdown-button, a.ui-dropdown-button
|
||||
{
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size);
|
||||
font-weight: 500;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
.-minor
|
||||
{
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-dim-one);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.has-icon
|
||||
{
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
|
||||
&:not([disabled]):hover .ui-dropdown-button-icon
|
||||
{
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-icon.is-negative:not([disabled]):hover .ui-dropdown-button-icon
|
||||
{
|
||||
color: var(--color-accent-error);
|
||||
}
|
||||
|
||||
&.is-multiline
|
||||
{
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&:not([disabled]):hover, &.is-active, &:focus
|
||||
{
|
||||
background: var(--color-dropdown-selected);
|
||||
color: var(--color-text);
|
||||
//font-weight: 700;
|
||||
|
||||
.ui-dropdown-list-item-icon
|
||||
{
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active
|
||||
{
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.is-active .ui-dropdown-button-icon
|
||||
{
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[disabled]
|
||||
{
|
||||
color: var(--color-text-dim);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
|
||||
.ui-dropdown-list-item-icon,
|
||||
.ui-dropdown-button-icon
|
||||
{
|
||||
color: var(--color-text-dim);
|
||||
}
|
||||
}
|
||||
|
||||
& + .ui-dropdown-button
|
||||
{
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dropdown-button-icon
|
||||
{
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-dropdown-button-progress
|
||||
{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
z-index: 2;
|
||||
border-radius: 40px;
|
||||
border: 2px solid transparent;
|
||||
border-left-color: var(--color-text);
|
||||
opacity: 1;
|
||||
will-change: transform;
|
||||
animation: rotating .5s linear infinite;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
|
||||
.ui-dropdown-button-confirmation
|
||||
{
|
||||
display: grid;
|
||||
flex-grow: 0;
|
||||
width: 100%;
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size);
|
||||
padding: 0 6px 0 16px;
|
||||
height: 48px;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 300px;
|
||||
|
||||
.ui-button + .ui-button
|
||||
{
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ui-button
|
||||
{
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating
|
||||
{
|
||||
from
|
||||
{
|
||||
-webkit-transform: rotate(0);
|
||||
transform: rotate(0)
|
||||
}
|
||||
to
|
||||
{
|
||||
-webkit-transform: rotate(1turn);
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="ui-dropdown-container" :class="{ 'is-open': open }" v-click-outside="hide">
|
||||
<slot name="before"></slot>
|
||||
<div v-if="hasButton" ref="trigger" class="ui-dropdown-toggle" @click.prevent.stop="toggle">
|
||||
<slot name="button"></slot>
|
||||
</div>
|
||||
<div class="ui-dropdown" ref="overlay" role="dialog" v-if="open" :class="dropdownClasses" :style="dropdownStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
//import Overlay from 'zero/helpers/overlay.js';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
const varKey = 'zero.ui-dropdown.current';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'uiDropdown',
|
||||
|
||||
props: {
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
handler(newValue, oldValue)
|
||||
{
|
||||
this.updateAlignment();
|
||||
}
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'dark'
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disablePositioning: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
hasButton()
|
||||
{
|
||||
return this.$slots.hasOwnProperty('button');
|
||||
},
|
||||
|
||||
dropdownClasses()
|
||||
{
|
||||
let classes = ['align-' + this.valign, 'align-' + this.halign];
|
||||
|
||||
if (!!this.theme)
|
||||
{
|
||||
classes.push('theme-' + this.theme);
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
|
||||
dropdownStyle()
|
||||
{
|
||||
if (this.topOffset != 0)
|
||||
{
|
||||
return `--v-offset: ${this.topOffset}px`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
open: false,
|
||||
valign: 'top',
|
||||
halign: 'left',
|
||||
topOffset: 0
|
||||
}),
|
||||
|
||||
|
||||
mounted()
|
||||
{
|
||||
this.updateAlignment();
|
||||
},
|
||||
|
||||
|
||||
methods: {
|
||||
|
||||
updateAlignment()
|
||||
{
|
||||
this.valign = this.align.indexOf('bottom') > -1 ? 'bottom' : 'top';
|
||||
this.halign = this.align.indexOf('right') > -1 ? 'right' : 'left';
|
||||
},
|
||||
|
||||
toggle()
|
||||
{
|
||||
if (this.open)
|
||||
{
|
||||
this.hide();
|
||||
}
|
||||
else if (!this.disabled)
|
||||
{
|
||||
this.show();
|
||||
}
|
||||
},
|
||||
|
||||
show()
|
||||
{
|
||||
if (this.disabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.zero.runtimeVariables[varKey])
|
||||
{
|
||||
this.zero.runtimeVariables[varKey].hide();
|
||||
}
|
||||
|
||||
this.zero.runtimeVariables[varKey] = this;
|
||||
|
||||
//Overlay.setDropdown(this);
|
||||
this.open = true;
|
||||
this.position();
|
||||
this.$emit('opened');
|
||||
},
|
||||
|
||||
hide()
|
||||
{
|
||||
if (this.locked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
this.open = false;
|
||||
this.$emit('closed');
|
||||
this.zero.runtimeVariables[varKey] = null;
|
||||
},
|
||||
|
||||
position()
|
||||
{
|
||||
if (this.disablePositioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.$nextTick(() =>
|
||||
{
|
||||
// the trigger which is the relative to the overlay
|
||||
const reference = this.$el;
|
||||
|
||||
// get bounding boxes both for the trigger and the overlay
|
||||
const triggerBoundingBox = reference ? reference.getBoundingClientRect() : { width: 0, height: 0, left: 0, top: 0 };
|
||||
const overlayBoundingBox = this.$refs.overlay.getBoundingClientRect();
|
||||
const windowBox = { width: window.innerWidth, height: window.innerHeight };
|
||||
const windowOffset = 32;
|
||||
|
||||
// calculate available space for the dropdown in all 4 directions areound the trigger element
|
||||
let availableSpace = {
|
||||
left: triggerBoundingBox.left + triggerBoundingBox.width - windowOffset,
|
||||
right: windowBox.width - triggerBoundingBox.left - windowOffset,
|
||||
top: triggerBoundingBox.top - windowOffset,
|
||||
bottom: windowBox.height - triggerBoundingBox.top - triggerBoundingBox.height - windowOffset
|
||||
};
|
||||
|
||||
if (this.valign == 'top' && availableSpace.bottom < overlayBoundingBox.height && availableSpace.top > availableSpace.bottom)
|
||||
{
|
||||
this.valign = 'bottom';
|
||||
}
|
||||
if (this.valign == 'bottom' && availableSpace.top < overlayBoundingBox.height && availableSpace.bottom > availableSpace.top)
|
||||
{
|
||||
this.valign = 'top';
|
||||
}
|
||||
|
||||
if (this.halign == 'left' && availableSpace.right < overlayBoundingBox.width && availableSpace.left > availableSpace.right)
|
||||
{
|
||||
this.halign = 'right';
|
||||
}
|
||||
if (this.halign == 'right' && availableSpace.left < overlayBoundingBox.width && availableSpace.right > availableSpace.left)
|
||||
{
|
||||
this.halign = 'left';
|
||||
}
|
||||
|
||||
const headerHeight = 90;
|
||||
const topOffset = ~~((availableSpace.top - headerHeight) - overlayBoundingBox.height) + 1;
|
||||
this.topOffset = this.valign == 'bottom' && topOffset < 0 ? topOffset : 0;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.ui-dropdown-container
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-dropdown
|
||||
{
|
||||
--v-offset: 0px;
|
||||
position: absolute;
|
||||
min-width: 300px;
|
||||
min-height: 20px;
|
||||
background: var(--color-dropdown);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-dropdown-border);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
z-index: 8;
|
||||
top: calc(100% + 5px);
|
||||
padding: 5px;
|
||||
color: var(--color-text);
|
||||
|
||||
&.align-right
|
||||
{
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.align-top
|
||||
{
|
||||
top: calc(100% + 5px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
&.align-bottom
|
||||
{
|
||||
bottom: calc(100% + 5px + var(--v-offset));
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type App } from 'vue';
|
||||
import uiIcon from './ui-icon.vue';
|
||||
import uiContextmenu from './ui-contextmenu.vue';
|
||||
import uiContextmenuButton from './ui-contextmenu-button.vue';
|
||||
|
||||
export function createComponents (app: App)
|
||||
{
|
||||
app.component('ui-icon', uiIcon);
|
||||
app.component('ui-contextmenu', uiContextmenu);
|
||||
app.component('ui-contextmenu-button', uiContextmenuButton);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<button :disabled="disabled" type="button" @click.stop="onClick" class="ui-contextmenu-button">
|
||||
<ui-icon v-if="symbol" :symbol="symbol" class="ui-contextmenu-button-icon" />
|
||||
<span v-else class="ui-contextmenu-button-icon is-placeholder"></span>
|
||||
<span class="ui-contextmenu-button-label" v-text="label" :class="{ 'is-minor': minor }"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string,
|
||||
symbol?: string,
|
||||
disabled?: boolean,
|
||||
autohide?: boolean,
|
||||
minor?: boolean
|
||||
}
|
||||
const { label, symbol, disabled, minor } = defineProps<Props>();
|
||||
|
||||
function onClick(ev: Event)
|
||||
{
|
||||
console.info('clicked');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.ui-contextmenu-button
|
||||
{
|
||||
appearance: none;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-inner);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, auto) 1fr;
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
background: var(--color-contextmenu-item-bg);
|
||||
padding: 0 var(--space-m);
|
||||
width: 100%;
|
||||
//transition: background-color 0.2s ease;
|
||||
|
||||
&[disabled]
|
||||
{
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover
|
||||
{
|
||||
cursor: pointer;
|
||||
background: var(--color-contextmenu-item-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-contextmenu-button-icon
|
||||
{
|
||||
height: 18px;
|
||||
stroke-width: 1.5px;
|
||||
color: var(--color-text-dim);
|
||||
margin-right: var(--space-s);
|
||||
}
|
||||
|
||||
.ui-contextmenu-button-label
|
||||
{
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text);
|
||||
|
||||
&.is-minor, .ui-contextmenu-button[disabled] &
|
||||
{
|
||||
color: var(--color-text-dim);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="ui-contextmenu-container" :class="{ 'is-open': open }">
|
||||
<div v-if="$slots.button" ref="trigger" class="ui-contextmenu-toggle" @click.prevent.stop="toggle">
|
||||
<slot name="button"></slot>
|
||||
</div>
|
||||
<div class="ui-contextmenu" ref="container" role="dialog" :hidden="!open" v-click-outside="hide">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, useHost, useTemplateRef, defineExpose, nextTick } from 'vue';
|
||||
import cache from '@/utils/cache';
|
||||
|
||||
const key = 'just.ui-contextmenu.current';
|
||||
const emit = defineEmits(['opened', 'closed']);
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
const { disabled } = defineProps<Props>();
|
||||
const $container = useTemplateRef('container');
|
||||
let open = ref(false);
|
||||
|
||||
|
||||
// toggles the context menu
|
||||
function toggle()
|
||||
{
|
||||
return open ? hide() : show();
|
||||
}
|
||||
|
||||
|
||||
// try to show the context menu
|
||||
function show(ev?: PointerEvent | undefined)
|
||||
{
|
||||
if (disabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// cache open context-menu so it can be auto-closed
|
||||
if (cache.has(key))
|
||||
{
|
||||
cache.get(key).hide();
|
||||
}
|
||||
cache.set(key, { hide });
|
||||
|
||||
open.value = true;
|
||||
if (ev != null)
|
||||
{
|
||||
nextTick(() =>
|
||||
{
|
||||
reposition(ev);
|
||||
});
|
||||
}
|
||||
emit('opened');
|
||||
}
|
||||
|
||||
|
||||
// hide this context menu
|
||||
function hide()
|
||||
{
|
||||
console.info('hide');
|
||||
open.value = false;
|
||||
emit('closed');
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
|
||||
function reposition({ pageY, pageX }: PointerEvent): void
|
||||
{
|
||||
const menuWidth = 100;
|
||||
const menuHeight = 300;
|
||||
|
||||
let position = {
|
||||
left: pageX + 2 - (menuWidth + pageX >= window.innerWidth ? menuWidth : 0),
|
||||
top: pageY + 2 - (menuHeight + pageY >= window.innerHeight ? menuHeight : 0)
|
||||
};
|
||||
|
||||
$container.value!.style.left = position.left + 'px';
|
||||
$container.value!.style.top = position.top + 'px';
|
||||
}
|
||||
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
reposition
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.ui-contextmenu
|
||||
{
|
||||
--v-offset: 0px;
|
||||
position: absolute;
|
||||
min-width: 220px;
|
||||
min-height: 20px;
|
||||
background: var(--color-contextmenu-bg);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-contextmenu-border);
|
||||
box-shadow: var(--color-contextmenu-shadow);
|
||||
z-index: 8;
|
||||
top: calc(100% + 5px);
|
||||
padding: 4px;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.align-right
|
||||
{
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.align-top
|
||||
{
|
||||
top: calc(100% + 5px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
&.align-bottom
|
||||
{
|
||||
bottom: calc(100% + 5px + var(--v-offset));
|
||||
top: auto;
|
||||
}
|
||||
|
||||
hr
|
||||
{
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-contextmenu-line); //var(--color-dropdown-line);
|
||||
margin: 4px 0;
|
||||
|
||||
& + hr
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +0,0 @@
|
||||
import { type Post } from '@/models';
|
||||
import createCollection from '@/db/createCollection.ts'
|
||||
|
||||
export const posts = await createCollection<Post>('posts');
|
||||
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"app.name": "Just"
|
||||
"app.name": "Just",
|
||||
"menu.lists.add": "Add a list",
|
||||
"menu.settings": "Settings"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createComponents } from '@/components'
|
||||
import { createDirectives } from '@/utils/directives'
|
||||
|
||||
import './styles/styles.scss'
|
||||
import App from './App.vue'
|
||||
@@ -10,8 +11,10 @@ import en_translations from './locales/en.json'
|
||||
const app = createApp(App)
|
||||
|
||||
createComponents(app)
|
||||
createDirectives(app)
|
||||
app.use(createPinia())
|
||||
app.use(createI18n<[typeof en_translations], 'en'>({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: en_translations
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export type Entity = {
|
||||
id: string,
|
||||
createdAt: number,
|
||||
lastModifiedAt: number,
|
||||
sort: number,
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export { type Post } from './post'
|
||||
export { type Entity } from './entity'
|
||||
export { type Task } from './task'
|
||||
export { type TaskList } from './taskList'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Entity } from './entity';
|
||||
|
||||
export type Task = {
|
||||
name: string,
|
||||
done: boolean,
|
||||
listId: string,
|
||||
dueAt?: Date,
|
||||
remindAt?: Date
|
||||
} & Entity;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type Entity } from './entity';
|
||||
|
||||
export type TaskList = {
|
||||
name: string
|
||||
} & Entity;
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<aside class="menu" :class="{ 'is-open': open }">
|
||||
<img class="logo" src="/just.svg" :alt="$t('app.name')" />
|
||||
<nav class="navigation">
|
||||
<navigation-item v-for="list in lists" :key="list.id" :name="list.name" :active="currentList == list" symbol="ListTodo" @contextmenu.prevent="onListRightClicked($event, list)" />
|
||||
<navigation-item :name="$t('menu.lists.add')" symbol="ListPlus" :minor="true" />
|
||||
<ui-contextmenu ref="contextmenu">
|
||||
<ui-contextmenu-button label="Rename" symbol="TextCursorInput" @click="rename(currentList!)" />
|
||||
<ui-contextmenu-button label="Duplicate" symbol="CopyPlus" />
|
||||
<hr />
|
||||
<ui-contextmenu-button label="Delete" symbol="Trash" />
|
||||
</ui-contextmenu>
|
||||
</nav>
|
||||
<div class="menu-bottom">
|
||||
<navigation-item :name="$t('menu.settings')" symbol="Bolt" :minor="true" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NavigationItem from './navigation-item.vue'
|
||||
import { onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { taskLists } from '@/stores'
|
||||
import { type TaskList } from '@/models'
|
||||
import UiContextmenuButton from '@/components/ui-contextmenu-button.vue'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
}
|
||||
const { open } = defineProps<Props>();
|
||||
|
||||
let lists: TaskList[] = reactive<TaskList[]>([]);
|
||||
let currentList: TaskList | undefined;
|
||||
let currentEvent: PointerEvent | undefined;
|
||||
let $contextmenu = useTemplateRef('contextmenu');
|
||||
|
||||
onMounted(async () =>
|
||||
{
|
||||
(await taskLists.getAll()).forEach(list =>
|
||||
{
|
||||
lists.push(list);
|
||||
});
|
||||
currentList = lists[0];
|
||||
});
|
||||
|
||||
function onListRightClicked(ev: PointerEvent, list: TaskList)
|
||||
{
|
||||
currentList = list;
|
||||
$contextmenu.value!.show(ev);
|
||||
}
|
||||
|
||||
function rename(list: TaskList)
|
||||
{
|
||||
console.info('rename: ' + list.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu
|
||||
{
|
||||
width: 260px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-menu-bg);
|
||||
align-self: stretch;
|
||||
overflow-y: auto;
|
||||
padding: var(--space);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
@media (max-width: 700px)
|
||||
{
|
||||
height: calc(100vh - 2 * var(--space-m));
|
||||
position: absolute;
|
||||
transform: translateX(-110%);
|
||||
transition: transform 0.6s ease;
|
||||
pointer-events: none;
|
||||
|
||||
&.is-open
|
||||
{
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo
|
||||
{
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.navigation
|
||||
{
|
||||
margin: var(--space) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +1,43 @@
|
||||
<template>
|
||||
<button type="button" class="navigation-item" :class="classes">
|
||||
<ui-icon :symbol="symbol" class="navigation-item-icon" />
|
||||
<span class="navigation-item-text" v-text="name"></span>
|
||||
<span v-if="!editing" class="navigation-item-text" v-text="name"></span>
|
||||
<span v-else>
|
||||
<input type="text" v-model="nameModel" class="navigation-item-input" />
|
||||
</span>
|
||||
<span class="navigation-item-count" v-if="count > 0" v-text="count"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref, defineExpose } from 'vue'
|
||||
|
||||
interface Props {
|
||||
symbol: string,
|
||||
name: string,
|
||||
count?: number,
|
||||
active?: boolean,
|
||||
minor?: boolean
|
||||
minor?: boolean,
|
||||
canSetName?: boolean
|
||||
}
|
||||
|
||||
const { symbol, name, count = 0, active, minor } = defineProps<Props>();
|
||||
|
||||
const { symbol, name, count = 0, active, minor, canSetName } = defineProps<Props>();
|
||||
const editing = ref(false);
|
||||
const nameModel = ref(name);
|
||||
const classes = reactive({
|
||||
'is-active': active,
|
||||
'is-list': !minor
|
||||
})
|
||||
|
||||
|
||||
function edit()
|
||||
{
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
edit
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -30,14 +45,14 @@
|
||||
{
|
||||
appearance: none;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-inner);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, auto) 1fr;
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-s);
|
||||
background: transparent;
|
||||
padding: 0 12px;
|
||||
padding: 0 var(--space-m);
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
@@ -71,7 +86,7 @@
|
||||
height: 20px;
|
||||
stroke-width: 1.5px;
|
||||
color: var(--color-text-dim);
|
||||
margin-right: 8px;
|
||||
margin-right: var(--space-s);
|
||||
}
|
||||
|
||||
.navigation-item-text
|
||||
@@ -103,4 +118,9 @@
|
||||
color: var(--color-text-dim);
|
||||
//transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.navigation-item-input
|
||||
{
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type Task, type TaskList } from '@/models';
|
||||
import createCollection from './createCollection.ts'
|
||||
|
||||
export const taskListsCollection = await createCollection<TaskList>('taskLists');
|
||||
export const tasksCollection = await createCollection<Task>('tasks');
|
||||
|
||||
export * as tasks from './tasks.ts';
|
||||
export * as taskLists from './lists.ts';
|
||||
@@ -0,0 +1,91 @@
|
||||
import { taskListsCollection, tasksCollection } from '.'
|
||||
import { type TaskList } from '@/models'
|
||||
|
||||
/**
|
||||
* Stores a new task list
|
||||
* @param name The name of the list
|
||||
* @returns The newly created task list
|
||||
*/
|
||||
export async function add(name: string): Promise<TaskList>
|
||||
{
|
||||
const modelBefore: TaskList | undefined = await taskListsCollection.find(undefined, {
|
||||
limit: 1,
|
||||
sort: {
|
||||
sort: -1
|
||||
},
|
||||
}).fetch().pop();
|
||||
|
||||
const sort: number = (modelBefore?.sort ?? 0) + 1;
|
||||
const date: number = Date.now();
|
||||
|
||||
return await taskListsCollection.insert({
|
||||
name,
|
||||
sort,
|
||||
createdAt: date,
|
||||
lastModifiedAt: date
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stores updated properties of a list
|
||||
* (only name + sort get updated)
|
||||
* @param model The existing list
|
||||
* @returns The updated task list
|
||||
*/
|
||||
export async function update(model: TaskList): Promise<TaskList>
|
||||
{
|
||||
const lastModifiedAt = Date.now();
|
||||
|
||||
let updated: boolean = (await taskListsCollection.updateOne({ id: model.id }, {
|
||||
$set: {
|
||||
lastModifiedAt,
|
||||
name: model.name,
|
||||
sort: model.sort
|
||||
}
|
||||
})) === 1;
|
||||
|
||||
if (updated)
|
||||
{
|
||||
model.lastModifiedAt = lastModifiedAt;
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Try to get a single task list from the store
|
||||
* @param id Id of the list
|
||||
* @returns The task list, if found
|
||||
*/
|
||||
export async function get(id: string): Promise<TaskList | undefined>
|
||||
{
|
||||
return await taskListsCollection.findOne({ id });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all task lists from the store
|
||||
* @returns Alle stored task lists
|
||||
*/
|
||||
export async function getAll(): Promise<TaskList[]>
|
||||
{
|
||||
return await taskListsCollection.find(undefined, {
|
||||
sort: {
|
||||
sort: 1
|
||||
}
|
||||
}).fetch();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tries to remove a task list by id.
|
||||
* This also removes all associated tasks.
|
||||
* @param id Id of the list
|
||||
*/
|
||||
export async function remove(id: string): Promise<void>
|
||||
{
|
||||
await taskListsCollection.removeOne({ id });
|
||||
await tasksCollection.removeMany({ listId: id });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
:root
|
||||
{
|
||||
--space-s: 8px;
|
||||
--space-m: 12px;
|
||||
--space: 16px;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-inner: 6px;
|
||||
}
|
||||
@@ -7,13 +7,38 @@
|
||||
src: url('./inter.woff2') format("woff2");
|
||||
}
|
||||
|
||||
html, body
|
||||
{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
color: var(--color-text);
|
||||
font-family: 'inter', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background: var(--color-layout-bg);
|
||||
|
||||
&.is-resizing *
|
||||
{
|
||||
transition: none !important;
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
|
||||
.app
|
||||
{
|
||||
width: min(800px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 30px;
|
||||
padding: 10px;
|
||||
height: 100vh;
|
||||
|
||||
@media (max-width: 700px)
|
||||
{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon
|
||||
@@ -23,3 +48,12 @@ body
|
||||
stroke-linejoin: round;
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
button
|
||||
{
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
@use './config';
|
||||
@use './theme';
|
||||
@use './reset';
|
||||
@use './main';
|
||||
@@ -6,7 +6,7 @@
|
||||
--color-bg-shade-2: #1D1D1D;
|
||||
--color-bg-shade-3: #232323;
|
||||
--color-bg-shade-4: #2D2D2D;
|
||||
--color-bg-shade-5: #343434;
|
||||
--color-bg-shade-5: #3A3A3A;
|
||||
|
||||
// define all foreground color shades
|
||||
--color-fg-shade-1: #FFFFFF;
|
||||
@@ -18,14 +18,22 @@
|
||||
--color-layout-icons-fg: var(--color-fg-shade-3);
|
||||
--color-layout-icons-hover-fg: var(--color-fg-shade-2);
|
||||
|
||||
// general text shades
|
||||
--color-text: var(--color-fg-shade-1);
|
||||
--color-text-dim: var(--color-fg-shade-2);
|
||||
--color-text-dim-one: var(--color-fg-shade-3);
|
||||
|
||||
// menu shades
|
||||
--color-menu-bg: var(--color-bg-shade-3);
|
||||
--color-menu-item-hover-bg: var(--color-bg-shade-4);
|
||||
--color-menu-item-count-bg: var(--color-bg-shade-4);
|
||||
--color-menu-item-hover-count-bg: var(--color-bg-shade-5);
|
||||
|
||||
// general text shades
|
||||
--color-text: var(--color-fg-shade-1);
|
||||
--color-text-dim: var(--color-fg-shade-2);
|
||||
--color-text-dim-one: var(--color-fg-shade-3);
|
||||
// contextmenu shades
|
||||
--color-contextmenu-bg: var(--color-bg-shade-4);
|
||||
--color-contextmenu-border: var(--color-bg-shade-5);
|
||||
--color-contextmenu-shadow: 3px 6px 12px rgba(0, 0, 0, 0.07);
|
||||
--color-contextmenu-line: var(--color-bg-shade-5);
|
||||
--color-contextmenu-item-bg: transparent;
|
||||
--color-contextmenu-item-hover-bg: var(--color-bg-shade-5);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
var cache: Map<string, any> = new Map();
|
||||
export default cache;
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { useResize } from './useResize';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useResize(callback: () => void) {
|
||||
onMounted(() => window.addEventListener('resize', callback))
|
||||
onUnmounted(() => window.removeEventListener('resize', callback))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type App } from 'vue';
|
||||
import clickOutside from './v-click-outside';
|
||||
|
||||
export function createDirectives (app: App)
|
||||
{
|
||||
app.directive('click-outside', clickOutside);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
const HANDLERS_PROPERTY = '__v-click-outside'
|
||||
const HAS_WINDOWS = typeof window !== 'undefined'
|
||||
const HAS_NAVIGATOR = typeof navigator !== 'undefined'
|
||||
const IS_TOUCH =
|
||||
HAS_WINDOWS &&
|
||||
('ontouchstart' in window ||
|
||||
(HAS_NAVIGATOR && navigator.msMaxTouchPoints > 0))
|
||||
const EVENTS = IS_TOUCH ? ['touchstart'] : ['click']
|
||||
|
||||
function processDirectiveArguments(bindingValue) {
|
||||
const isFunction = typeof bindingValue === 'function'
|
||||
if (!isFunction && typeof bindingValue !== 'object') {
|
||||
throw new Error(
|
||||
'v-click-outside: Binding value must be a function or an object',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
handler: isFunction ? bindingValue : bindingValue.handler,
|
||||
middleware: bindingValue.middleware || ((item) => item),
|
||||
events: bindingValue.events || EVENTS,
|
||||
isActive: !(bindingValue.isActive === false),
|
||||
detectIframe: !(bindingValue.detectIframe === false),
|
||||
capture: !!bindingValue.capture,
|
||||
}
|
||||
}
|
||||
|
||||
function execHandler({ event, handler, middleware }) {
|
||||
if (middleware(event)) {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
function onFauxIframeClick({ el, event, handler, middleware }) {
|
||||
// Note: on firefox clicking on iframe triggers blur, but only on
|
||||
// next event loop it becomes document.activeElement
|
||||
// https://stackoverflow.com/q/2381336#comment61192398_23231136
|
||||
setTimeout(() => {
|
||||
const { activeElement } = document
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement.tagName === 'IFRAME' &&
|
||||
!el.contains(activeElement)
|
||||
) {
|
||||
execHandler({ event, handler, middleware })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function onEvent({ el, event, handler, middleware }) {
|
||||
// Note: composedPath is not supported on IE and Edge, more information here:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath
|
||||
// In the meanwhile, we are using el.contains for those browsers, not
|
||||
// the ideal solution, but using IE or EDGE is not ideal either.
|
||||
const path = (event.composedPath && event.composedPath()) || event.path
|
||||
const isClickOutside = path
|
||||
? path.indexOf(el) < 0
|
||||
: !el.contains(event.target)
|
||||
|
||||
if (!isClickOutside) {
|
||||
return
|
||||
}
|
||||
|
||||
execHandler({ event, handler, middleware })
|
||||
}
|
||||
|
||||
function bind(el, { value }) {
|
||||
const {
|
||||
events,
|
||||
handler,
|
||||
middleware,
|
||||
isActive,
|
||||
detectIframe,
|
||||
capture,
|
||||
} = processDirectiveArguments(value)
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
el[HANDLERS_PROPERTY] = events.map((eventName) => ({
|
||||
event: eventName,
|
||||
srcTarget: document.documentElement,
|
||||
handler: (event) => onEvent({ el, event, handler, middleware }),
|
||||
capture,
|
||||
}))
|
||||
|
||||
if (detectIframe) {
|
||||
const detectIframeEvent = {
|
||||
event: 'blur',
|
||||
srcTarget: window,
|
||||
handler: (event) => onFauxIframeClick({ el, event, handler, middleware }),
|
||||
capture,
|
||||
}
|
||||
|
||||
el[HANDLERS_PROPERTY] = [...el[HANDLERS_PROPERTY], detectIframeEvent]
|
||||
}
|
||||
|
||||
el[HANDLERS_PROPERTY].forEach(({ event, srcTarget, handler }) =>
|
||||
setTimeout(() => {
|
||||
// Note: More info about this implementation can be found here:
|
||||
// https://github.com/ndelvalle/v-click-outside/issues/137
|
||||
if (!el[HANDLERS_PROPERTY]) {
|
||||
return
|
||||
}
|
||||
srcTarget.addEventListener(event, handler, capture)
|
||||
}, 0),
|
||||
)
|
||||
}
|
||||
|
||||
function unbind(el) {
|
||||
const handlers = el[HANDLERS_PROPERTY] || []
|
||||
handlers.forEach(({ event, srcTarget, handler, capture }) =>
|
||||
srcTarget.removeEventListener(event, handler, capture),
|
||||
)
|
||||
delete el[HANDLERS_PROPERTY]
|
||||
}
|
||||
|
||||
function update(el, { value, oldValue }) {
|
||||
if (JSON.stringify(value) === JSON.stringify(oldValue)) {
|
||||
return
|
||||
}
|
||||
unbind(el)
|
||||
bind(el, { value })
|
||||
}
|
||||
|
||||
export default HAS_WINDOWS ? {
|
||||
beforeMount: bind,
|
||||
updated: update,
|
||||
unmounted: unbind
|
||||
} : {}
|
||||
Reference in New Issue
Block a user