Repository: bluerobotics/cockpit
Pull request #1734: Add quick access to widgets configs
Author: login: ArturoManzoli
Labels: name: docs-complete
description: Change documentation has been completed
Created: 2025-03-06T19:11:58Z | Merged: 2025-03-28T20:01:32Z

- Users can now use a right-click or a long press to open a context menu on the widgets;
- Widgets and mini widgets are now configurable outside edit-mode;
- Cockpit elements opacity can be quickly set using the context menu;
- `v-contextmenu` directive added for easy binding the context menu's triggering and opening location;

https://github.com/user-attachments/assets/fc9d4849-0fbd-4d5c-8fae-5fc39bbc510c

Closes #1610 


Linked issue #1610: There should be a quick way to access widgets configuration menus
Some solutions were brought up:
- Long click widget
- Right click widget
- Right click anywhere to enter configuration mode (not edit-mode)
- Click a button to enter configuration mode (not edit-mode)

Diff:
diff --git a/src/components/ContextMenu.vue b/src/components/ContextMenu.vue
new file mode 100644
index 0000000000..26c7b3bae5
--- /dev/null
+++ b/src/components/ContextMenu.vue
@@ -0,0 +1,165 @@
+<template>
+  <teleport to="body">
+    <Transition name="fade">
+      <div v-if="isContextMenuOpen" class="fixed z-[99999]" :style="positionStyles" @click.stop>
+        <div
+          class="flex flex-col rounded-md shadow-lg"
+          :style="[
+            interfaceStore.globalGlassMenuStyles,
+            { background: '#33333333', border: '1px solid #FFFFFF44' },
+            { width: width },
+          ]"
+        >
+          <template v-if="menuItems">
+            <div
+              v-for="(item, index) in menuItems"
+              :key="index"
+              class="flex justify-between items-center px-4 h-10 hover:bg-[#FFFFFF11] cursor-pointer text-[14px]"
+              @click="handleItemClick(item)"
+            >
+              <p class="mb-1">{{ item.item }}</p>
+              <v-icon v-if="item.icon" :icon="item.icon" size="16" class="text-[#FFFFFF88] ml-4" />
+            </div>
+            <v-divider v-if="menuItems.length > 1 || ($slots.default && menuItems.length === 1)" />
+          </template>
+          <template v-if="$slots.default">
+            <slot :close="handleClose" />
+          </template>
+        </div>
+      </div>
+    </Transition>
+  </teleport>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  CSSProperties,
+  defineEmits,
+  defineExpose,
+  defineProps,
+  getCurrentInstance,
+  onBeforeUnmount,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from 'vue'
+
+import { useAppInterfaceStore } from '@/stores/appInterface'
+import { useWidgetManagerStore } from '@/stores/widgetManager'
+import { ContextMenuItem } from '@/types/user-interface'
+
+const props = defineProps<{
+  /**
+   *
+   */
+  visible: boolean
+  /**
+   *
+   */
+  menuItems?: ContextMenuItem[]
+  /**
+   *
+   */
+  width?: string
+}>()
+
+const emit = defineEmits<{ (e: 'close'): void }>()
+const interfaceStore = useAppInterfaceStore()
+const widgetStore = useWidgetManagerStore()
+
+const isContextMenuOpen = ref(false)
+
+watch(
+  () => props.visible,
+  (newValue) => {
+    isContextMenuOpen.value = newValue
+  }
+)
+
+const internalPosition = reactive({ x: 0, y: 0 })
+
+const positionStyles = computed<CSSProperties>(() => {
+  const isAboveCenter = internalPosition.y < window.innerHeight / 2
+  const isInLastQuarter = internalPosition.x > window.innerWidth * 0.75
+
+  const style: CSSProperties = {
+    userSelect: 'none',
+    top: isAboveCenter ? internalPosition.y + 'px' : 'auto',
+    bottom: !isAboveCenter ? window.innerHeight - internalPosition.y + 'px' : 'auto',
+  }
+
+  if (isInLastQuarter) {
+    style.right = window.innerWidth - internalPosition.x + 'px'
+  } else {
+    style.left = internalPosition.x + 'px'
+  }
+
+  return style
+})
+
+const width = computed(() => props.width || 'auto')
+
+const handleItemClick = (item: ContextMenuItem): void => {
+  item.action?.()
+  handleClose()
+}
+
+const handleClose = (): void => {
+  isContextMenuOpen.value = false
+  if (widgetStore.currentContextMenu === instance) {
+    widgetStore.currentContextMenu = null
+  }
+  emit('close')
+}
+
+const handleOutsideClick = (): void => {
+  handleClose()
+}
+
+onMounted(() => {
+  document.addEventListener('click', handleOutsideClick)
+})
+
+onBeforeUnmount(() => {
+  document.removeEventListener('click', handleOutsideClick)
+})
+
+const instance = getCurrentInstance()
+
+// Position the context menu at the given coordinates.
+const openAt = (event: MouseEvent | TouchEvent): void => {
+  let x = 0,
+    y = 0
+  if ('clientX' in event) {
+    x = event.clientX
+    y = event.clientY
+  } else if (event.touches && event.touches.length) {
+    x = event.touches[0].clientX
+    y = event.touches[0].clientY
+  }
+  internalPosition.x = x
+  internalPosition.y = y
+
+  if (widgetStore.currentContextMenu && widgetStore.currentContextMenu !== instance) {
+    widgetStore.currentContextMenu.exposed?.handleClose()
+  }
+  widgetStore.currentContextMenu = instance
+
+  isContextMenuOpen.value = true
+}
+
+defineExpose({ openAt, handleClose })
+</script>
+
+<style scoped>
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s;
+}
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>
diff --git a/src/components/InputElementConfig.vue b/src/components/InputElementConfig.vue
index 4cee6fbd34..24af8d67c1 100644
--- a/src/components/InputElementConfig.vue
+++ b/src/components/InputElementConfig.vue
@@ -1,6 +1,7 @@
 <template>
   <div
-    class="flex fixed w-[250px] h-[78vh] right-0 top-0 border-l-[1px] border-[#FFFFFF44] text-white elevation-5 bg-[#051e2d]"
+    class="flex fixed w-[250px] right-0 top-0 border-l-[1px] border-[#FFFFFF44] text-white elevation-5 bg-[#051e2d]"
+    :style="getMarginsFromBarsHeight"
   >
     <div class="flex flex-col text-center">
       <v-btn
@@ -430,6 +431,15 @@ watch(
   }
 )
 
+const getMarginsFromBarsHeight = computed(() => {
+  return {
+    marginTop: widgetStore.currentTopBarHeightPixels + 'px',
+    marginBottom: widgetStore.currentBottomBarHeightPixels + 'px',
+    height:
+      window.innerHeight - widgetStore.currentTopBarHeightPixels - widgetStore.currentBottomBarHeightPixels - 1 + 'px',
+  }
+})
+
 const handleResetVariable = (): void => {
   currentElement.value!.options.dataLakeVariable = undefined
   futureDataLakeVariable.value = defaultDataLakeVariable
diff --git a/src/components/MiniWidgetInstantiator.vue b/src/components/MiniWidgetInstantiator.vue
index 70c7857cb3..8a7ce4786e 100644
--- a/src/components/MiniWidgetInstantiator.vue
+++ b/src/components/MiniWidgetInstantiator.vue
@@ -1,13 +1,39 @@
 <template>
-  <component :is="componentFromType(miniWidget.component)" :mini-widget="miniWidget" />
+  <div v-contextmenu="handleContextMenu" :style="{ opacity: miniWidget.options.opacity ?? 1 }">
+    <component :is="componentFromType(miniWidget.component)" :mini-widget="miniWidget" />
+  </div>
+  <ContextMenu
+    ref="contextMenuRef"
+    :visible="contextMenuVisible"
+    :menu-items="contextMenuItems"
+    width="200px"
+    @close="contextMenuVisible = false"
+  >
+    <template #default>
+      <div class="flex justify-between items-center pr-4 h-10 hover:bg-[#FFFFFF11] text-[14px]">
+        <div class="w-full -ml-1">
+          <v-slider
+            v-model="OpacitySlider"
+            color="white"
+            min="0.2"
+            max="1"
+            step="0.01"
+            class="scale-75 h-[32px] opacity-75"
+          />
+        </div>
+        <v-icon icon="mdi-circle-opacity" size="16" class="text-[#FFFFFF88] rotate-180" />
+      </div>
+    </template>
+  </ContextMenu>
 </template>
 
 <script setup lang="ts">
-import { defineAsyncComponent, onMounted, toRefs } from 'vue'
+import { computed, defineAsyncComponent, onMounted, ref, toRefs } from 'vue'
 
+import ContextMenu from '@/components/ContextMenu.vue'
 import { createDataLakeVariable, getDataLakeVariableInfo } from '@/libs/actions/data-lake'
 import { useWidgetManagerStore } from '@/stores/widgetManager'
-import { type MiniWidget, CustomWidgetElement } from '@/types/widgets'
+import { type MiniWidget, CustomWidgetElement, isMiniWidgetConfigurable, MiniWidgetType } from '@/types/widgets'
 
 const widgetStore = useWidgetManagerStore()
 
@@ -19,8 +45,47 @@ const props = defineProps<{
 }>()
 
 const miniWidget = toRefs(props).miniWidget
-
 const componentCache: Record<string, ReturnType<typeof defineAsyncComponent>> = {}
+const contextMenuRef = ref()
+const contextMenuVisible = ref(false)
+
+const handleContextMenu = {
+  open: (event: MouseEvent | TouchEvent) => {
+    contextMenuRef.value.openAt(event)
+    contextMenuVisible.value = true
+  },
+  close: () => {
+    contextMenuVisible.value = false
+  },
+}
+
+const OpacitySlider = computed({
+  get() {
+    return miniWidget.value.options?.opacity ?? 1
+  },
+  set(newValue) {
+    if (miniWidget.value.options) {
+      miniWidget.value.options.opacity = newValue
+    }
+  },
+})
+
+const isConfigMenuDisabled = computed(() => {
+  if ('isCustomElement' in miniWidget.value && miniWidget.value.isCustomElement) return false
+  if (isMiniWidgetConfigurable[miniWidget.value.component as MiniWidgetType]) {
+    return false
+  }
+  return true
+})
+
+const openWidgetConfig = (): void => {
+  contextMenuVisible.value = false
+  widgetStore.miniWidgetManagerVars(miniWidget.value.hash).configMenuOpen = true
+}
+
+const contextMenuItems = computed(() =>
+  isConfigMenuDisabled.value ? [] : [{ item: 'Options', action: openWidgetConfig, icon: 'mdi-cog' }]
+)
 
 const componentFromType = (componentType: string): ReturnType<typeof defineAsyncComponent> => {
   if (!componentCache[componentType]) {
diff --git a/src/components/WidgetHugger.vue b/src/components/WidgetHugger.vue
index d99488ac29..bdd28a075e 100644
--- a/src/components/WidgetHugger.vue
+++ b/src/components/WidgetHugger.vue
@@ -15,7 +15,13 @@
     :class="{ allowMoving, draggingWidget, hoveringWidgetOrOverlay, highlighted }"
   />
   <div ref="outerWidgetRef" class="outerWidget">
-    <div ref="innerWidgetRef" class="innerWidget" :class="{ 'overflow-hidden': hideOverflow }">
+    <div
+      ref="innerWidgetRef"
+      v-contextmenu="handleContextMenu"
+      class="innerWidget"
+      :class="{ 'overflow-hidden': hideOverflow }"
+      :style="{ opacity: widget.options.opacity ?? 1 }"
+    >
       <slot></slot>
     </div>
     <div class="resize-handle top-left" :class="{ hoveringWidgetOrOverlay, allowResizing }" />
@@ -27,6 +33,29 @@
     <div class="resize-handle top" :class="{ hoveringWidgetOrOverlay, allowResizing }" />
     <div class="resize-handle bottom" :class="{ hoveringWidgetOrOverlay, allowResizing }" />
   </div>
+  <ContextMenu
+    ref="contextMenuRef"
+    :visible="contextMenuVisible"
+    :menu-items="contextMenuItems"
+    width="200px"
+    @close="contextMenuVisible = false"
+  >
+    <template #default>
+      <div class="flex justify-between items-center pr-4 h-10 hover:bg-[#FFFFFF11] text-[14px]">
+        <div class="w-full -ml-1">
+          <v-slider
+            v-model="opacitySlider"
+            color="white"
+            min="0.2"
+            max="1"
+            step="0.01"
+            class="scale-75 h-[32px] opacity-75"
+          />
+        </div>
+        <v-icon icon="mdi-circle-opacity" size="16" class="text-[#FFFFFF88] rotate-180" />
+      </div>
+    </template>
+  </ContextMenu>
 </template>
 
 <script setup lang="ts">
@@ -37,7 +66,9 @@ import { constrain, round } from '@/libs/utils'
 import { useDevelopmentStore } from '@/stores/development'
 import { useWidgetManagerStore } from '@/stores/widgetManager'
 import type { Point2D } from '@/types/general'
-import type { Widget } from '@/types/widgets'
+import { type Widget, isWidgetConfigurable, WidgetType } from '@/types/widgets'
+
+import ContextMenu from './ContextMenu.vue'
 
 /**
  * Props for the WidgetHugger component
@@ -75,6 +106,39 @@ const outerWidgetRef = ref<HTMLElement | undefined>()
 const innerWidgetRef = ref<HTMLElement | undefined>()
 const widgetView = computed(() => outerWidgetRef.value?.parentElement)
 const widgetResizeHandles = computed(() => outerWidgetRef.value?.getElementsByClassName('resize-handle'))
+const contextMenuRef = ref()
+const contextMenuVisible = ref(false)
+
+const opacitySlider = computed({
+  get() {
+    return widget.value.options?.opacity ?? 1
+  },
+  set(newValue) {
+    if (widget.value.options) {
+      widget.value.options.opacity = newValue
+    }
+  },
+})
+
+const handleContextMenu = {
+  open: (event: MouseEvent | TouchEvent) => {
+    contextMenuRef.value.openAt(event)
+    contextMenuVisible.value = true
+  },
+  close: () => {
+    contextMenuVisible.value = false
+  },
+}
+
+const openWidgetConfig = (): void => {
+  widgetStore.widgetManagerVars(widget.value.hash).configMenuOpen = true
+}
+
+const contextMenuItems = computed(() =>
+  isWidgetConfigurable[widget.value.component as WidgetType]
+    ? [{ item: 'Options', action: openWidgetConfig, icon: 'mdi-cog' }]
+    : []
+)
 
 const devStore = useDevelopmentStore()
 
diff --git a/src/components/widgets/CollapsibleContainer.vue b/src/components/widgets/CollapsibleContainer.vue
index ab17ff9cbb..45a989a365 100644
--- a/src/components/widgets/CollapsibleContainer.vue
+++ b/src/components/widgets/CollapsibleContainer.vue
@@ -153,93 +153,102 @@
   </div>
 
   <Teleport to="body">
-    <GlassModal :is-visible="widgetStore.widgetManagerVars(widget.hash).configMenuOpen">
-      <v-card class="px-8 pb-6 pt-2 rounded-lg w-[400px] bg-transparent">
-        <v-card-title class="text-center -mt-1">Custom Widget options</v-card-title>
-        <v-btn
-          class="absolute top-3 right-0 text-lg rounded-full"
-          variant="text"
-          size="small"
-          @click="widgetStore.widgetManagerVars(widget.hash).configMenuOpen = false"
-        >
-          <v-icon>mdi-close</v-icon>
-        </v-btn>
-        <div class="flex flex-col justify-start items-start gap-x-4 mt-3 w-full">
-          <p class="text-start">Name:</p>
-          <v-text-field ref="nameInput" v-model="widget.name" density="compact" class="w-3/4" />
-          <p class="text-start">Columns:</p>
-          <v-text-field
-            ref="nameInput"
-            v-model="widget.options.columns"
-            min="1"
-            max="2"
-            type="number"
-            density="compact"
-            class="w-1/4"
-          />
-          <p class="mt-1">Background color</p>
-          <input v-model="widget.options.backgroundColor" type="color" class="p-0 w-20 mr-4" />
-          <p class="mt-3">Background opacity:</p>
-          <v-slider v-model="widget.options.backgroundOpacity" min="0" max="1" color="white" thumb-label width="250" />
-          <p class="mt-3">Background blur:</p>
-          <v-slider v-model="widget.options.backgroundBlur" min="0" max="100" color="white" thumb-label width="250" />
-        </div>
-      </v-card>
-    </GlassModal>
-
-    <Transition>
-      <div
-        v-if="showWidgetTrashArea"
-        ref="widgetTrashArea"
-        class="absolute w-32 h-32 -translate-x-32 -translate-y-32 bottom-[20%] left-1/3 bg-[#FF000055] z-[65] rounded-xl flex items-center justify-center hover:bg-slate-200/50 transition-all"
-      >
-        <div class="relative flex justify-center items-center w-full h-full">
-          <FontAwesomeIcon
-            icon="fa-solid fa-trash"
-            class="absolute h-16 transition-all -translate-x-7 -translate-y-8 top-1/2 left-1/2 text-white"
-          />
-          <VueDraggable
-            v-model="trashList"
-            :animation="150"
-            group="generalGroup"
-            class="flex flex-wrap items-center justify-center w-full h-full gap-2"
-            @add="handleDeleteWidget"
+    <v-dialog v-model="widgetStore.widgetManagerVars(widget.hash).configMenuOpen" persistent>
+      <GlassModal :is-visible="widgetStore.widgetManagerVars(widget.hash).configMenuOpen">
+        <v-card class="px-8 pb-6 pt-2 rounded-lg w-[400px] bg-transparent">
+          <v-card-title class="text-center -mt-1">Custom Widget options</v-card-title>
+          <v-btn
+            class="absolute top-3 right-0 text-lg rounded-full"
+            variant="text"
+            size="small"
+            @click="widgetStore.widgetManagerVars(widget.hash).configMenuOpen = false"
           >
-            <div v-for="miniWidget in trashList" :key="miniWidget.hash">
-              <div class="select-none">
-                <MiniWidgetInstantiator :mini-widget="miniWidget" />
+            <v-icon>mdi-close</v-icon>
+          </v-btn>
+          <div class="flex flex-col justify-start items-start gap-x-4 mt-3 w-full">
+            <p class="text-start">Name:</p>
+            <v-text-field ref="nameInput" v-model="widget.name" density="compact" class="w-3/4" />
+            <p class="text-start">Columns:</p>
+            <v-text-field
+              ref="nameInput"
+              v-model="widget.options.columns"
+              min="1"
+              max="2"
+              type="number"
+              density="compact"
+              class="w-1/4"
+            />
+            <p class="mt-1">Background color</p>
+            <input v-model="widget.options.backgroundColor" type="color" class="p-0 w-20 mr-4" />
+            <p class="mt-3">Background opacity:</p>
+            <v-slider
+              v-model="widget.options.backgroundOpacity"
+              min="0"
+              max="1"
+              color="white"
+              thumb-label
+              width="250"
+            />
+            <p class="mt-3">Background blur:</p>
+            <v-slider v-model="widget.options.backgroundBlur" min="0" max="100" color="white" thumb-label width="250" />
+          </div>
+        </v-card>
+      </GlassModal>
+
+      <Transition>
+        <div
+          v-if="showWidgetTrashArea"
+          ref="widgetTrashArea"
+          class="absolute w-32 h-32 -translate-x-32 -translate-y-32 bottom-[20%] left-1/3 bg-[#FF000055] z-[65] rounded-xl flex items-center justify-center hover:bg-slate-200/50 transition-all"
+        >
+          <div class="relative flex justify-center items-center w-full h-full">
+            <FontAwesomeIcon
+              icon="fa-solid fa-trash"
+              class="absolute h-16 transition-all -translate-x-7 -translate-y-8 top-1/2 left-1/2 text-white"
+            />
+            <VueDraggable
+              v-model="trashList"
+              :animation="150"
+              group="generalGroup"
+              class="flex flex-wrap items-center justify-center w-full h-full gap-2"
+              @add="handleDeleteWidget"
+            >
+              <div v-for="miniWidget in trashList" :key="miniWidget.hash">
+                <div class="select-none">
+                  <MiniWidgetInstantiator :mini-widget="miniWidget" />
+                </div>
               </div>
-            </div>
-          </VueDraggable>
+            </VueDraggable>
+          </div>
         </div>
-      </div>
-    </Transition>
-
-    <GlassModal :is-visible="selectViewToShareDialog">
-      <v-card class="px-3 pb-6 pt-2 rounded-lg w-auto bg-transparent z-40">
-        <v-card-title class="flex justify-around -mt-1 w-full px-0">
-          <div />
-          <p class="mx-8">Clone widget to</p>
-          <v-icon class="cursor-pointer self-end" @click="selectViewToShareDialog = false">mdi-close</v-icon>
-        </v-card-title>
-        <div class="flex w-full justify-center items-center mt-4">
-          <select
-            v-model="selectedViewToShareWidget"
-            class="bg-[#50505022] dark:bg-gray-800 dark:text-white w-[180px] p-2 border border-gray-300 dark:border-gray-600 rounded appearance-none"
-            @change="handleCopyWidgetToView"
-          >
-            <option
-              v-for="view in widgetStore.currentProfile.views"
-              :key="view.name"
-              :value="view.name"
-              class="bg-gray-800 dark:bg-gray-800 dark:text-white"
+      </Transition>
+
+      <GlassModal :is-visible="selectViewToShareDialog">
+        <v-card class="px-3 pb-6 pt-2 rounded-lg w-auto bg-transparent z-40">
+          <v-card-title class="flex justify-around -mt-1 w-full px-0">
+            <div />
+            <p class="mx-8">Clone widget to</p>
+            <v-icon class="cursor-pointer self-end" @click="selectViewToShareDialog = false">mdi-close</v-icon>
+          </v-card-title>
+          <div class="flex w-full justify-center items-center mt-4">
+            <select
+              v-model="selectedViewToShareWidget"
+              class="bg-[#50505022] dark:bg-gray-800 dark:text-white w-[180px] p-2 border border-gray-300 dark:border-gray-600 rounded appearance-none"
+              @change="handleCopyWidgetToView"
             >
-              {{ view.name }}
-            </option>
-          </select>
-        </div>
-      </v-card>
-    </GlassModal>
+              <option
+                v-for="view in widgetStore.currentProfile.views"
+                :key="view.name"
+                :value="view.name"
+                class="bg-gray-800 dark:bg-gray-800 dark:text-white"
+              >
+                {{ view.name }}
+              </option>
+            </select>
+          </div>
+        </v-card>
+      </GlassModal>
+    </v-dialog>
   </Teleport>
 </template>
 
diff --git a/src/directives/contextMenu.ts b/src/directives/contextMenu.ts
new file mode 100644
index 0000000000..43895301ca
--- /dev/null
+++ b/src/directives/contextMenu.ts
@@ -0,0 +1,84 @@
+import { DirectiveBinding } from 'vue'
+
+/**
+ * Context menu opener type
+ */
+export interface ContextMenu {
+  /**
+   * Open the context menu
+   */
+  open: (event: MouseEvent | TouchEvent) => void
+  /**
+   * Close the context menu
+   */
+  close: () => void
+}
+
+// Global reference to the currently open context menu opener
+let currentContextMenu: {
+  /**
+   * Open/closes the context menu
+   */
+  close: () => void
+} | null = null
+
+const longPressDuration = 500
+let touchTimer: ReturnType<typeof setTimeout> | null = null
+
+export const contextMenu = {
+  mounted(el: HTMLElement, binding: DirectiveBinding<ContextMenu>) {
+    const { value: opener } = binding
+
+    if (typeof opener.open !== 'function' || typeof opener.close !== 'function') {
+      console.warn('v-contextmenu-opener binding value must be an object with open and close functions')
+      return
+    }
+
+    const onContextMenu = (event: MouseEvent): void => {
+      event.preventDefault()
+      event.stopPropagation()
+      // If there is another open context menu (from a different element), close it.
+      if (currentContextMenu && currentContextMenu !== opener) {
+        currentContextMenu.close()
+      }
+      opener.open(event)
+      currentContextMenu = opener
+    }
+
+    const onTouchStart = (event: TouchEvent): void => {
+      touchTimer = setTimeout(() => {
+        if (currentContextMenu && currentContextMenu !== opener) {
+          currentContextMenu.close()
+        }
+        opener.open(event)
+        currentContextMenu = opener
+      }, longPressDuration)
+    }
+
+    const onTouchEnd = (): void => {
+      if (touchTimer) {
+        clearTimeout(touchTimer)
+        touchTimer = null
+      }
+    }
+
+    el.addEventListener('contextmenu', onContextMenu)
+    el.addEventListener('touchstart', onTouchStart)
+    el.addEventListener('touchend', onTouchEnd)
+    el.addEventListener('touchcancel', onTouchEnd)
+
+    // Store the event listeners for cleanup.
+    ;(el as any).__contextMenuHandlers = { onContextMenu, onTouchStart, onTouchEnd }
+  },
+
+  unmounted(el: HTMLElement) {
+    const handlers = (el as any).__contextMenuHandlers
+    if (handlers) {
+      el.removeEventListener('contextmenu', handlers.onContextMenu)
+      el.removeEventListener('touchstart', handlers.onTouchStart)
+      el.removeEventListener('touchend', handlers.onTouchEnd)
+      el.removeEventListener('touchcancel', handlers.onTouchEnd)
+      delete (el as any).__contextMenuHandlers
+    }
+  },
+}
diff --git a/src/main.ts b/src/main.ts
index 019d0ede80..b6734e1f5d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -18,6 +18,7 @@ import eventTracker from '@/libs/external-telemetry/event-tracking'
 import { runMigrations } from '@/utils/migrations'
 
 import App from './App.vue'
+import { contextMenu } from './directives/contextMenu'
 import { setupPredefinedLakeAndActionResources } from './libs/joystick/protocols/predefined-resources'
 import { setupPostPiniaConnections } from './libs/post-pinia-connections'
 import vuetify from './plugins/vuetify'
@@ -55,6 +56,7 @@ if (window.localStorage.getItem('cockpit-enable-usage-statistics-telemetry') &&
 
 app.component('FontAwesomeIcon', FontAwesomeIcon)
 app.component('VueDraggableResizable', VueDraggableResizable)
+app.directive('contextmenu', contextMenu)
 app.use(router).use(vuetify).use(createPinia()).use(FloatingVue).use(VueVirtualScroller)
 app.mount('#app')
 
diff --git a/src/stores/widgetManager.ts b/src/stores/widgetManager.ts
index c11b896491..bcc09a69d3 100644
--- a/src/stores/widgetManager.ts
+++ b/src/stores/widgetManager.ts
@@ -17,7 +17,6 @@ import { miniWidgetsProfile } from '@/assets/defaults'
 import { useInteractionDialog } from '@/composables/interactionDialog'
 import { resetJustMadeKey, useBlueOsStorage } from '@/composables/settingsSyncer'
 import { openSnackbar } from '@/composables/snackbar'
-import { useSnackbar } from '@/composables/snackbar'
 import { MavType } from '@/libs/connection/m2r/messages/mavlink2rest-enum'
 import * as Words from '@/libs/funny-name/words'
 import {
@@ -45,7 +44,6 @@ import {
 } from '@/types/widgets'
 
 const { showDialog } = useInteractionDialog()
-const { openSnackbar } = useSnackbar()
 
 export const savedProfilesKey = 'cockpit-saved-profiles-v8'
 
@@ -71,6 +69,7 @@ export const useWidgetManagerStore = defineStore('widget-manager', () => {
   const widgetToEdit = ref<Widget>()
   const miniWidgetLastValues = useBlueOsStorage<Record<string, any>>('cockpit-mini-widget-last-values', {})
   const floatingWidgetContainers = ref<MiniWidgetContainer[]>([])
+  const currentContextMenu = ref<any | null>(null)
 
   const editWidgetByHash = (hash: string): Widget | undefined => {
     widgetToEdit.value = currentProfile.value.views
@@ -934,5 +933,6 @@ export const useWidgetManagerStore = defineStore('widget-manager', () => {
     setMiniWidgetLastValue,
     getMiniWidgetLastValue,
     copyWidgetToView,
+    currentContextMenu,
   }
 })
diff --git a/src/types/user-interface.ts b/src/types/user-interface.ts
new file mode 100644
index 0000000000..7f6afaa9f4
--- /dev/null
+++ b/src/types/user-interface.ts
@@ -0,0 +1,31 @@
+/**
+ * Context menu
+ */
+export interface IContextMenu {
+  /**
+   * Close the context menu
+   */
+  handleClose: () => void
+  /**
+   * The position of the context menu
+   */
+  openAt: (event: MouseEvent | TouchEvent) => void
+}
+
+/**
+ * Context Menu Item
+ */
+export interface ContextMenuItem {
+  /**
+   * The item name to display
+   */
+  item: string
+  /**
+   * The icon to display
+   */
+  icon?: string
+  /**
+   * The action to perform
+   */
+  action: () => void
+}


Reviews:
user: login: ES-Alexander
body: - Seems to work with a mouse 👍 
- Also works with touch events on my phone, but long pressing also highlights part of the interface and/or opens some other phone behaviour (e.g. saving a widget as a photo), which can be disconcerting
- Perhaps an outspoken opinion, but I am a little concerned it might be _too_ accessible, in a way that makes the whole interface feel a little cluttered 🤷 
    - If everything is an interactive element then it's possible the whole application feels less stable
    - In my mind rapid configuration is helpful when you're setting up your interface to start with, and for components that regularly need changes (like the View switcher), but it could also be desirable to have a sense of "I am done configuring most things for now" at which point configuration actions are suppressed (maybe #1427 could help with that, akin to turning on parental guidance on your own phone to help manage your screen time)
- Agreed there's some mental conflict with the left click context menu from the mission planning page, but that can be thought about more later
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:17:24Z
user: login: ES-Alexander
body: I'm still a little unsure on the value vs cost of introducing this, but the work has already been done, and it does resolve an open issue without seeming to introduce obvious problems (the mobile highlighting can be dealt with later as part of a larger overhaul).

@rafaellehmkuhl if you're not against it then I think it can be merged in 🤷 :-)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:12:33Z
user: login: rafaellehmkuhl
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:40:10Z
user: login: ArturoManzoli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:51:29Z
user: login: ArturoManzoli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:56:59Z

Review comments:
diff_hunk: @@ -153,93 +153,102 @@
   </div>
 
   <Teleport to="body">
-    <GlassModal :is-visible="widgetStore.widgetManagerVars(widget.hash).configMenuOpen">
path: src/components/widgets/CollapsibleContainer.vue
user: login: rafaellehmkuhl
body: Hey @ArturoManzoli, could you elaborate on what's being done in this commit? I'm kinda lost.
created_at: 2025-03-28T16:00:04Z
author_association: MEMBER
diff_hunk: @@ -430,6 +431,24 @@ watch(
   }
 )
 
+const getMarginsFromBarsHeight = computed(() => {
+  if (!widgetStore.editingMode) {
+    return {
+      marginTop: widgetStore.currentTopBarHeightPixels + 'px',
+      marginBottom: widgetStore.currentBottomBarHeightPixels + 'px',
+      height:
+        window.innerHeight -
+        widgetStore.currentTopBarHeightPixels -
+        widgetStore.currentBottomBarHeightPixels -
+        1 +
+        'px',
+    }
+  }
+  return {
+    height: '78vh',
+  }
+})
+
path: src/components/InputElementConfig.vue
user: login: rafaellehmkuhl
body: Maybe we should use the calculated size on both situations?
created_at: 2025-03-28T16:13:12Z
author_association: MEMBER
diff_hunk: @@ -153,93 +153,102 @@
   </div>
 
   <Teleport to="body">
-    <GlassModal :is-visible="widgetStore.widgetManagerVars(widget.hash).configMenuOpen">
path: src/components/widgets/CollapsibleContainer.vue
user: login: ArturoManzoli
body: This had to be adjusted because the collapsible-container had a GlassModal component which has no backdrop layer. 
So, a v-dialog was added to block interaction with other widgets on the background while the config modal was open. Also, by doing so, this change ensured that the config element aligned with the standards of other widget config screens.
created_at: 2025-03-28T18:51:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -430,6 +431,24 @@ watch(
   }
 )
 
+const getMarginsFromBarsHeight = computed(() => {
+  if (!widgetStore.editingMode) {
+    return {
+      marginTop: widgetStore.currentTopBarHeightPixels + 'px',
+      marginBottom: widgetStore.currentBottomBarHeightPixels + 'px',
+      height:
+        window.innerHeight -
+        widgetStore.currentTopBarHeightPixels -
+        widgetStore.currentBottomBarHeightPixels -
+        1 +
+        'px',
+    }
+  }
+  return {
+    height: '78vh',
+  }
+})
+
path: src/components/InputElementConfig.vue
user: login: ArturoManzoli
body: Fixed!

created_at: 2025-03-28T18:56:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: ArturoManzoli
created_at: 2025-03-10T12:05:24Z
body: There is currently another ContextMenu component on the MissionPlanning module that will be replaced by this new one on a separate PR.
author_association: CONTRIBUTOR
user: login: ArturoManzoli
created_at: 2025-03-25T21:56:40Z
body: >   * If everything is an interactive element then it's possible the whole application feels less stable

>   * In my mind rapid configuration is helpful when you're setting up your interface to start with, and for components that regularly need changes (like the View switcher), but it could also be desirable to have a sense of "I am done configuring most things for now" at which point configuration actions are suppressed (maybe [frontend: add reduced configuration mode(s) #1427](https://github.com/bluerobotics/cockpit/issues/1427) could help with that, akin to turning on parental guidance on your own phone to help manage your screen time)
> * Agreed there's some mental conflict with the left click context menu from the mission planning page, but that can be thought about more later

Yes, I agree that an interface with too many interactive elements can be overwhelming and cause confusion. We have been focusing on an advanced use Cockpit with many features and things to set up. This is another one of them. 
On a future 'simple mode' for Cockpit, this feature will not be available.
One possible option could be a toggle that enables interface editing. However, we have already implemented this through the 'Edit-Interface Mode' and the recently added 'Enter Edit Mode' mini widget.

author_association: CONTRIBUTOR
user: login: rafaellehmkuhl
created_at: 2025-03-28T15:50:39Z
body: > I'm still a little unsure on the value vs cost of introducing this, but the work has already been done, and it does resolve an open issue without seeming to introduce obvious problems (the mobile highlighting can be dealt with later as part of a larger overhaul).
> 
> @rafaellehmkuhl if you're not against it then I think it can be merged in 🤷 :-)

I think it has its value, and we have an open discussion about complexity vs usage-easiness to be done, but it does not need to hold this PR.

I will just review the code before we merge it.
author_association: MEMBER