Create component `PageHeaderActionMenu.vue`

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>
This commit is contained in:
Phillip Rak 2024-10-02 10:09:32 -07:00
parent cb6c4d1bfd
commit 21050a3695
3 changed files with 271 additions and 141 deletions

View File

@ -21,6 +21,7 @@ import { getApplicableExtensionEnhancements } from '@shell/core/plugin-helpers';
import IconOrSvg from '@shell/components/IconOrSvg';
import { wait } from '@shell/utils/async';
import { authProvidersInfo, parseAuthProvidersInfo } from '@shell/utils/auth';
import HeaderPageActionMenu from './HeaderPageActionMenu.vue';
export default {
@ -35,6 +36,7 @@ export default {
ClusterProviderIcon,
IconOrSvg,
AppModal,
HeaderPageActionMenu,
},
props: {
@ -72,8 +74,20 @@ export default {
},
computed: {
...mapGetters(['clusterReady', 'isExplorer', 'isRancher', 'currentCluster',
'currentProduct', 'rootProduct', 'backToRancherLink', 'backToRancherGlobalLink', 'pageActions', 'isSingleProduct', 'isRancherInHarvester', 'showTopLevelMenu']),
...mapGetters([
'clusterReady',
'isExplorer',
'isRancher',
'currentCluster',
'currentProduct',
'rootProduct',
'backToRancherLink',
'backToRancherGlobalLink',
'pageActions',
'isSingleProduct',
'isRancherInHarvester',
'showTopLevelMenu'
]),
authProviderEnabled() {
const authProviders = this.$store.getters['management/all'](MANAGEMENT.AUTH_CONFIG);
@ -294,14 +308,6 @@ export default {
this.showSearchModal = false;
},
showPageActionsMenu(show) {
this.isPageActionMenuOpen = show;
},
pageAction(action) {
this.$store.dispatch('handlePageAction', action);
},
checkClusterName() {
this.$nextTick(() => {
const el = this.$refs.clusterName;
@ -610,57 +616,7 @@ export default {
</button>
</div>
<div
v-if="showPageActions"
id="page-actions"
class="actions"
>
<i
data-testid="page-actions-menu"
class="icon icon-actions"
tabindex="0"
@blur="showPageActionsMenu(false)"
@click="showPageActionsMenu(true)"
@focus.capture="showPageActionsMenu(true)"
/>
<v-dropdown
:triggers="[]"
:shown="isPageActionMenuOpen"
:autoHide="false"
:flip="false"
:content="false"
:placement="'bottom-end'"
:distance="14"
:container="'#page-actions'"
>
<template #popper>
<div class="user-menu">
<ul
data-testid="page-actions-dropdown"
class="list-unstyled dropdown"
@click.stop="showPageActionsMenu(false)"
>
<li
v-for="(a, i) in pageActions"
:key="i"
class="user-menu-item"
>
<a
v-if="!a.separator"
@click="pageAction(a)"
>{{ a.labelKey ? t(a.labelKey) : a.label }}</a>
<div
v-else
class="menu-separator"
>
<div class="menu-separator-line" />
</div>
</li>
</ul>
</div>
</template>
</v-dropdown>
</div>
<header-page-action-menu v-if="showPageActions" />
<div class="header-spacer" />
<div
@ -1002,20 +958,6 @@ export default {
}
}
.actions {
align-items: center;
cursor: pointer;
display: flex;
> I {
font-size: 18px;
padding: 6px;
&:hover {
color: var(--link);
}
}
}
.header-spacer {
background-color: var(--header-bg);
position: relative;
@ -1113,70 +1055,4 @@ export default {
color: var(--secondary);
}
#page-actions {
:deep() .v-popper__arrow-container {
display: none;
}
}
.user-menu {
:deep() .v-popper__arrow-container {
display: none;
}
// Remove the default padding on the popup so that the hover on menu items goes full width of the menu
:deep() .v-popper__inner {
padding: 0 0 10px 0;
}
:deep() .v-popper {
display: flex;
}
}
.actions {
:deep() .v-popper:focus {
outline: 0;
}
.dropdown {
margin: 0 -10px;
}
}
.user-menu-item {
a, &.no-link > span {
cursor: pointer;
padding: 0px 10px;
&:hover {
background-color: var(--dropdown-hover-bg);
color: var(--dropdown-hover-text);
text-decoration: none;
}
// When the menu item is focused, pop the margin and compensate the padding, so that
// the focus border appears within the menu
&:focus {
margin: 0 2px;
padding: 10px 8px;
}
}
&.no-link > span {
display: flex;
justify-content: space-between;
padding: 10px;
color: var(--link);
}
div.menu-separator {
cursor: default;
padding: 4px 0;
.menu-separator-line {
background-color: var(--border);
height: 1px;
}
}
}
</style>

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useClickOutside } from '@shell/composables/useClickOutside';
const isPageActionMenuOpen = ref(false);
const showPageActionsMenu = (show: boolean) => {
isPageActionMenuOpen.value = show;
};
const store = useStore();
const pageActions = computed(() => store.getters.pageActions);
const pageAction = (action: string) => {
store.dispatch('handlePageAction', action);
showPageActionsMenu(false);
};
const target = ref(null);
useClickOutside(target, () => showPageActionsMenu(false));
const handleBlurEvent = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
showPageActionsMenu(false);
}
};
</script>
<template>
<v-dropdown
class="actions"
:triggers="[]"
:shown="isPageActionMenuOpen"
:autoHide="false"
:flip="false"
:placement="'bottom-end'"
:distance="-6"
>
<i
data-testid="page-actions-menu"
class="icon icon-actions"
tabindex="0"
@keydown="handleBlurEvent"
@click="showPageActionsMenu(true)"
@focus.capture="showPageActionsMenu(true)"
/>
<template #popper>
<div
ref="target"
class="user-menu"
>
<ul
data-testid="page-actions-dropdown"
class="list-unstyled dropdown"
>
<li
v-for="(a) in pageActions"
:key="a.label"
class="user-menu-item"
>
<a
v-if="!a.separator"
@click="pageAction(a)"
>{{ a.labelKey ? t(a.labelKey) : a.label }}</a>
<div
v-else
class="menu-separator"
>
<div class="menu-separator-line" />
</div>
</li>
</ul>
</div>
</template>
</v-dropdown>
</template>
<class lang="scss" scoped>
.v-popper__popper {
.v-popper__wrapper {
.v-popper__arrow-container {
display: none;
}
.v-popper__inner {
padding: 10px 0 10px 0;
}
}
}
.icon-actions:focus {
outline: 0;
}
.user-menu-item {
a, &.no-link > span {
cursor: pointer;
padding: 0px 10px;
&:hover {
background-color: var(--dropdown-hover-bg);
color: var(--dropdown-hover-text);
text-decoration: none;
}
// When the menu item is focused, pop the margin and compensate the padding, so that
// the focus border appears within the menu
&:focus {
margin: 0 2px;
padding: 10px 8px;
}
}
&.no-link > span {
display: flex;
justify-content: space-between;
padding: 10px;
color: var(--link);
}
div.menu-separator {
cursor: default;
padding: 4px 0;
.menu-separator-line {
background-color: var(--border);
height: 1px;
}
}
}
.actions {
align-items: center;
cursor: pointer;
display: flex;
> I {
font-size: 18px;
padding: 6px;
&:hover {
color: var(--link);
}
}
:deep(.v-popper:focus) {
outline: 0;
}
.dropdown {
margin: 0 -10px;
}
}
.list-unstyled {
li {
a {
display: flex;
justify-content: space-between;
padding: 10px;
}
&.user-info {
display: block;
margin-bottom: 10px;
padding: 10px 20px;
border-bottom: solid 1px var(--border);
min-width: 200px;
}
}
}
</class>

View File

@ -0,0 +1,81 @@
/**
* useClickOutside is based on onClickOutside from VueUse (https://github.com/vueuse/vueuse/blob/main/packages/core/onClickOutside/index.ts)
*
* This was originally reimplemented due to a resolution bug found in Yarn 1.x
* that involves mapping a html-webpack-plugin-5 alias to html-webpack-plugin.
* This bug is unrelated to VueUse, but would break vue/vue-cli as they rely on
* an un-aliased version of html-webpack-plugin.
*
* @note Although there are minor differences between this implementation and
* the original, we can easily replace this implementation with VueUse if we
* find that we will benefit from importing the library in the future.
*/
import { onMounted, onBeforeUnmount } from 'vue';
export interface OnClickOutsideOptions {
/**
* List of elements that should not trigger the event.
*/
ignore?: string[]
}
export const useClickOutside = <T extends OnClickOutsideOptions>(
component: any,
callback: any,
options: T = {} as T,
) => {
const { ignore = [] } = options;
let shouldListen = true;
const shouldIgnore = (event: PointerEvent) => {
return ignore.some((target) => {
if (typeof target === 'string') {
return Array.from(window.document.querySelectorAll(target))
.some((el) => el === event.target || event.composedPath().includes(el));
} else {
const el = target;
return el && (event.target === el || event.composedPath().includes(el));
}
});
};
const listener = (event: PointerEvent) => {
const el = component.value;
if (!el || el === event.target || event.composedPath().includes(el)) {
return;
}
if (event.detail === 0) {
shouldListen = !shouldIgnore(event);
}
if (!shouldListen) {
shouldListen = true;
return;
}
if (typeof callback === 'function') {
callback();
}
};
const setShouldListen = (e: any) => {
const el = component.value;
shouldListen = !shouldIgnore(e) && !!(el && !e.composedPath().includes(el));
};
onMounted(() => {
window.addEventListener('click', listener as any);
window.addEventListener('pointerdown', setShouldListen);
});
onBeforeUnmount(() => {
window.removeEventListener('click', listener as any);
window.removeEventListener('pointerDown', setShouldListen);
});
};