User popover (#599)

* Change large/x-large sizes

* Force update to popper on content change

* Use smaller size avatar in module bar

* Make the readme up to date

* Add support for trigger / delay props on v-menu

* Add user-popover component, use in comments drawer detail

* Add loading state to user popoer

* Fix trigger target in comments drawer etail item header
This commit is contained in:
Rijk van Zanten
2020-05-20 16:38:31 -04:00
committed by GitHub
parent 97b543e553
commit 66e6af7a3d
10 changed files with 293 additions and 201 deletions

View File

@@ -95,18 +95,18 @@ Vue.component('v-tab-item', VTabItem);
Vue.component('v-textarea', VTextarea);
Vue.component('v-upload', VUpload);
import DrawerDetail from '@/views/private/components/drawer-detail/';
Vue.component('drawer-detail', DrawerDetail);
import TransitionExpand from './transition/expand';
Vue.component('transition-expand', TransitionExpand);
import RenderDisplay from '@/views/private/components/render-display';
import RenderTemplate from '@/views/private/components/render-template';
import DrawerDetail from '@/views/private/components/drawer-detail/';
import FilterDrawerDetail from '@/views/private/components/filter-drawer-detail';
import UserPopover from '@/views/private/components/user-popover';
Vue.component('render-display', RenderDisplay);
Vue.component('render-template', RenderTemplate);
Vue.component('filter-drawer-detail', FilterDrawerDetail);
Vue.component('drawer-detail', DrawerDetail);
Vue.component('user-popover', UserPopover);

View File

@@ -64,11 +64,11 @@ body {
}
&.large {
--v-avatar-size: 56px;
--v-avatar-size: 64px;
}
&.x-large {
--v-avatar-size: 64px;
--v-avatar-size: 80px;
}
::v-deep {

View File

@@ -7,119 +7,19 @@ Renders a dropdown menu. Can be attached to an activator element or free floatin
Due to the fact that a menu is rendered through a portal, dialogs don't work great when rendered from
within a menu. If you ever find yourself doing this:
```html
<v-menu>
<v-list>
<v-dialog>
<template #activator="{ on }">
<v-list-item @click="on">
```
You're better off doing
```html
<v-dialog v-model="dialogActive">
<v-menu>
<v-list>
<v-list-item @click="dialogActive = true">
```
## Usage
Can be used with an activator in the corresponding slot:
```html
<v-menu>
<template v-slot:activator>
<v-button>Click me</v-button>
</template>
<v-list>
<v-list-item v-for="i in [1, 2, 3]" @click="() => {}">
Item 1
</v-list-item>
</v-list>
</v-menu>
```
Or without using the activator slot and using absolute positioning:
```vue
<template>
<img @click="show" />
<v-menu :value="showMenu":positionX="pos.x" :positionY="pos.y" :absolute="true">
<v-list>
<v-list-item v-for="i in [1, 2, 3]" key="i" @click="() => {}">
Item 1
</v-list-item>
</v-list>
</v-menu>
</template>
<script>
export default {
setup(_, { root }) {
const showMenu = ref(false);
const pos = reactive({ x: 0, y: 0 });
function show(e: MouseEvent) {
e.preventDefault();
if (showMenu.value === false) {
const { x, y } = toRefs(pos);
x.value = e.clientX;
y.value = e.clientY;
}
root.$nextTick(() => {
showMenu.value = !showMenu.value;
});
}
return { show, pos, showMenu };
}
},
</script>
```
## Props
### Positioning
| Prop | Description | Default |
| ----------- | ---------------------------------------------------------------------------- | ----------- |
| `absolute` | Applies absolute positioning to the menu | `false` |
| `fixed` | Applies the fixed attribute to the menu | `false` |
| `auto` | Centers the menu, if possible | `false` |
| `top` | Aligns the menu to the top of the activator, going up (if possible) | `false` |
| `bottom` | Aligns the menu to the bottom of the activator, going down (if possible) | `false` |
| `left` | Aligns the menu to the left of the activator, expanding left (if possible) | `false` |
| `right` | Aligns the menu to the right of the activator, expanding right (if possible) | `false` |
| `offset-x` | Positions the menu along the X-Axis so as not to cover any of the activator | `false` |
| `offset-y` | Positions the menu along the Y-Axis so as not to cover any of the activator | `false` |
| `position-x` | "left" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
| `position-y` | "top" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
### Behavior
| Prop | Description | Default |
|-----------------------|-------------------------------------------------------------|---------|
| `closeOnClick` | Closes the menu when user clicks somewhere else | `true` |
| `closeOnContentClick` | Closes the menu when user clicks on a menu item | `false` |
| `openOnClick` | Open the menu when activator is clicked | `true` |
| `openOnHover` | Open the menu when activator is hovered over | `false` |
| `openDelay` | Delay in milliseconds after hover enter for menu to open | `0` |
| `closeDelay` | Delay in milliseconds after hover leave for menu to close | `0` |
| `overflow-scroll` | Overflow the content in the menu when it reaches max height | `true` |
### Control
| Prop | Description | Default |
| ---------- | ---------------------------------- | ----------- |
| `value` | Value to control menu active state | `undefined` |
| `disabled` | Menu does not appear | `false` |
### NOTES
1. You do not have to set a click listener or attach a value when using the activator slot. `v-menu` automatically wraps the activator and listens for a click on the wrapper, and models that value to the menu content's internal active state.
2. All positioning props are dependent on whether there is room (without causing page overflow) for the menu to follow them. If there is not, it will compensate so that it remains entirely on screen.
3. Default behavior is to align to the bottom and to the right. Therefore if both `bottom` and `top` and set, `top` takes precedent, and with `left` and `right` set, `left` takes precedent. `Auto` takes precedent over all of these.
| Prop | Description | Default |
|--------------------------|-------------------------------------------------------------------|-------------|
| `placement` | Where to position the popper. | `bottom` |
| `value` | Value to control menu active state | `undefined` |
| `close-on-click` | Close the menu when clicking outside of the menu | `true` |
| `close-on-content-click` | Close the menu when clicking the content of the menu | `false` |
| `attached` | Attach the menu to an input | `false` |
| `show-arrow` | Show an arrow pointer | `false` |
| `disabled` | Menu does not appear | `false` |
| `trigger` | Activate the menu on a trigger. One of `manual`, `click`, `hover` | `null` |
| `delay` | Time in ms before menu activates after trigger | `0` |
## Slots

View File

@@ -33,6 +33,10 @@ export function usePopper(
});
});
const observer = new MutationObserver(() => {
popperInstance.value?.forceUpdate();
});
return { popperInstance, placement, start, stop, styles, arrowStyles };
function start() {
@@ -44,11 +48,19 @@ export function usePopper(
strategy: 'fixed',
});
popperInstance.value.forceUpdate();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
observer.observe(popper.value!, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
});
}
function stop() {
popperInstance.value?.destroy();
observer.disconnect();
}
function getModifiers(callback: () => void = () => undefined) {

View File

@@ -1,6 +1,12 @@
<template>
<div class="v-menu">
<div ref="activator" class="v-menu-activator" :class="{ attached }">
<div class="v-menu" @click="onClick">
<div
ref="activator"
class="v-menu-activator"
:class="{ attached }"
@pointerenter="onPointerEnter"
@pointerleave="onPointerLeave"
>
<slot
name="activator"
v-bind="{
@@ -92,6 +98,15 @@ export default defineComponent({
type: Boolean,
default: false,
},
trigger: {
type: String,
default: null,
validator: (val: string) => ['hover', 'click'].includes(val),
},
delay: {
type: Number,
default: 0,
},
},
setup(props, { emit }) {
const activator = ref<HTMLElement>(null);
@@ -121,6 +136,10 @@ export default defineComponent({
});
});
const { onClick, onPointerEnter, onPointerLeave } = useEvents();
const hoveringOnPopperContent = ref(false);
return {
id,
activator,
@@ -135,6 +154,10 @@ export default defineComponent({
arrowStyles,
popperPlacement,
activate,
onClick,
onPointerLeave,
onPointerEnter,
hoveringOnPopperContent,
};
function useActiveState() {
@@ -187,6 +210,36 @@ export default defineComponent({
deactivate();
}
}
function useEvents() {
let timeout: ReturnType<typeof setTimeout> | null = null;
return { onClick, onPointerLeave, onPointerEnter };
function onClick() {
if (props.trigger !== 'click') return;
toggle();
}
function onPointerEnter() {
if (props.trigger !== 'hover') return;
if (timeout) return;
timeout = setTimeout(() => {
activate();
}, props.delay);
}
function onPointerLeave() {
if (hoveringOnPopperContent.value === true) return;
if (props.trigger !== 'hover') return;
if (timeout === null) return;
clearTimeout(timeout);
deactivate();
timeout = null;
}
}
},
});
</script>
@@ -411,64 +464,6 @@ body {
}
}
// .bounce-enter,
// .bounce-leave-to {
// & [data-placement='top'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='top-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='top-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='right'] > .v-menu-content {
// transform: scaleX(0.8);
// }
// & [data-placement='right-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='right-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='bottom'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='bottom-start'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='bottom-end'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='left'] > .v-menu-content {
// transform: scaleX(0.8);
// }
// & [data-placement='left-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='left-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// }
// .bounce-enter-active > .v-menu-content,
// .bounce-leave-active > .v-menu-content {
// transform: scaleY(1) scaleX(1);
// transition-timing-function: cubic-bezier(0, 0, 0.2, 1.5);
// transition-duration: var(--fast);
// }
.attached {
&[data-placement^='top'] {
> .v-menu-content {

View File

@@ -1,22 +1,24 @@
<template>
<span class="user" :class="display">
<img
v-if="(display === 'avatar' || display === 'both') && src"
:src="src"
role="presentation"
:alt="value && `${value.first_name} ${value.last_name}`"
:class="{ circle }"
/>
<img
v-else-if="(display === 'avatar' || display === 'both') && src === null"
src="../../assets/avatar-placeholder.svg"
role="presentation"
:alt="value && `${value.first_name} ${value.last_name}`"
:class="{ circle }"
/>
<span v-if="display === 'name' || display === 'both'">
{{ value.first_name }} {{ value.last_name }}
</span>
<user-popover v-if="value" :user="value.id">
<img
v-if="(display === 'avatar' || display === 'both') && src"
:src="src"
role="presentation"
:alt="value && `${value.first_name} ${value.last_name}`"
:class="{ circle }"
/>
<img
v-else-if="(display === 'avatar' || display === 'both') && src === null"
src="../../assets/avatar-placeholder.svg"
role="presentation"
:alt="value && `${value.first_name} ${value.last_name}`"
:class="{ circle }"
/>
<span v-if="display === 'name' || display === 'both'">
{{ value.first_name }} {{ value.last_name }}
</span>
</user-popover>
</span>
</template>

View File

@@ -10,13 +10,20 @@
</v-avatar>
<div class="name">
<template v-if="activity.action_by && activity.action_by">
{{ activity.action_by.first_name }} {{ activity.action_by.last_name }}
</template>
<user-popover
v-if="activity.action_by && activity.action_by.id"
:user="activity.action_by.id"
>
<span>
<template v-if="activity.action_by && activity.action_by">
{{ activity.action_by.first_name }} {{ activity.action_by.last_name }}
</template>
<template v-else>
{{ $t('private_user') }}
</template>
<template v-else>
{{ $t('private_user') }}
</template>
</span>
</user-popover>
</div>
<div class="header-right">

View File

@@ -27,7 +27,7 @@
</v-dialog>
<router-link :to="userProfileLink">
<v-avatar tile x-large v-tooltip.right="userFullName">
<v-avatar tile large v-tooltip.right="userFullName">
<img v-if="avatarURL" :src="avatarURL" :alt="userFullName" class="avatar-image" />
<v-icon v-else name="account_circle" />
</v-avatar>

View File

@@ -0,0 +1,4 @@
import UserPopover from './user-popover.vue';
export { UserPopover };
export default UserPopover;

View File

@@ -0,0 +1,172 @@
<template>
<v-menu show-arrow placement="top" trigger="hover" :delay="500" v-model="active">
<template #activator><slot /></template>
<div class="loading" v-if="loading">
<v-skeleton-loader class="avatar" />
<div>
<v-skeleton-loader type="text" />
<v-skeleton-loader type="text" />
<v-skeleton-loader type="text" />
</div>
</div>
<div class="error" v-else-if="error">
{{ error }}
</div>
<div class="user-box" v-else-if="data">
<v-avatar x-large class="avatar">
<img v-if="avatarSrc" :src="avatarSrc" :alt="data.first_name" />
<v-icon name="person" v-else />
</v-avatar>
<div class="data">
<div class="name type-title">{{ data.first_name }} {{ data.last_name }}</div>
<div class="status-role" :class="data.status">
{{ data.status }} {{ data.role.name }}
</div>
<div class="email">{{ data.email }}</div>
</div>
</div>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, ref, watch, onUnmounted, computed } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
type User = {
first_name: string;
last_name: string;
email: string;
avatar: {
data: {
thumbnails: any[];
};
};
};
export default defineComponent({
props: {
user: {
type: Number,
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const loading = ref(false);
const error = ref(null);
const data = ref<User>(null);
const avatarSrc = computed(() => {
if (data.value === null) return null;
return data.value.avatar?.data?.thumbnails?.find(
(thumbnail) => thumbnail.key === 'directus-medium-crop'
)?.url;
});
const active = ref(false);
watch(active, () => {
if (active.value === true && data.value === null && loading.value === false) {
fetchUser();
}
});
onUnmounted(() => {
loading.value = false;
error.value = null;
data.value = null;
});
return { loading, error, data, active, avatarSrc };
async function fetchUser() {
loading.value = true;
error.value = null;
const { currentProjectKey } = projectsStore.state;
try {
const response = await api.get(`/${currentProjectKey}/users/${props.user}`, {
params: {
fields: [
'first_name',
'last_name',
'avatar.data',
'role.name',
'status',
'email',
],
},
});
data.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.hover-trigger {
width: max-content;
}
.user-box {
display: flex;
height: 80px;
margin: 10px 6px;
.v-avatar {
margin-right: 16px;
}
.status-role {
&.active {
color: var(--success);
}
}
.email {
color: var(--foreground-subdued);
}
}
.trigger {
cursor: help;
&:hover {
border-bottom: 2px dotted var(--foreground-subdued);
}
}
.loading {
--v-skeleton-loader-background-color: var(--background-normal);
display: flex;
align-items: center;
height: 80px;
margin: 10px 6px;
.avatar {
width: 80px;
height: 80px;
margin-right: 16px;
}
div {
width: 140px;
.v-skeleton-loader:not(:last-child) {
margin-bottom: 12px;
}
}
}
</style>