Skip to content

Commit

Permalink
implement dropdown menu with delete button for nodes list
Browse files Browse the repository at this point in the history
  • Loading branch information
liamcottle committed Nov 17, 2024
1 parent aeb9df7 commit e8b2ce3
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 7 deletions.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@meshtastic/js": "^2.3.7-5",
"@vitejs/plugin-vue": "^5.2.0",
"click-outside-vue3": "^4.0.1",
"moment": "^2.30.1",
"vite": "^5.4.11",
"vue": "^3.5.12",
Expand Down
92 changes: 92 additions & 0 deletions src/components/DropDownMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<template>
<div v-click-outside="{ handler: onClickOutsideMenu, capture: true }" class="relative inline-block text-left">

<!-- menu button -->
<div ref="dropdown-button" @click.stop="toggleMenu">
<slot name="button"/>
</div>

<!-- drop down menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none" :class="[ dropdownClass ]">
<slot name="items"/>
</div>
</Transition>

</div>
</template>

<script>
export default {
name: 'DropDownMenu',
data() {
return {
isShowingMenu: false,
dropdownClass: null,
};
},
methods: {
toggleMenu() {
if(this.isShowingMenu){
this.hideMenu();
} else {
this.showMenu();
}
},
showMenu() {
this.isShowingMenu = true;
this.adjustDropdownPosition();
},
hideMenu() {
this.isShowingMenu = false;
},
onClickOutsideMenu(event) {
if(this.isShowingMenu){
event.preventDefault();
this.hideMenu();
}
},
adjustDropdownPosition() {
this.$nextTick(() => {
// find button and dropdown
const button = this.$refs["dropdown-button"];
const dropdown = button.nextElementSibling;
// do nothing if not found
if(!button || !dropdown){
return;
}
// get bounding box of button and dropdown
const buttonRect = button.getBoundingClientRect();
const dropdownRect = dropdown.getBoundingClientRect();
// calculate how much space is under and above the button
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
const spaceAboveButton = buttonRect.top;
// calculate if there is enough space available to show dropdown
const hasEnoughSpaceAboveButton = spaceAboveButton > dropdownRect.height;
const hasEnoughSpaceBelowButton = spaceBelowButton > dropdownRect.height;
// show dropdown above button
if(hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton){
this.dropdownClass = "bottom-0 mb-12";
return;
}
// otherwise fallback to showing dropdown below button
this.dropdownClass = "top-0 mt-12";
});
},
},
}
</script>
21 changes: 21 additions & 0 deletions src/components/DropDownMenuItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<template>
<div class="group flex items-center p-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 hover:outline-none">

<!-- icon -->
<div class="mr-2 text-gray-400 group-hover:text-gray-500">
<slot name="icon"/>
</div>

<!-- label -->
<div>
<slot name="label"/>
</div>

</div>
</template>

<script>
export default {
name: 'DropDownMenuItem',
}
</script>
6 changes: 4 additions & 2 deletions src/components/nodes/NodeIcon.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<template>
<div class="flex rounded-full h-12 w-12 text-white shadow" :style="{ backgroundColor: getNodeColour(node.num), color: getNodeTextColour(node.num)}">
<div class="mx-auto my-auto drop-shadow-sm">{{ getNodeShortName(node.num) }}</div>
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :style="{ backgroundColor: getNodeColour(node.num), color: getNodeTextColour(node.num)}">
<div class="mx-auto my-auto drop-shadow-sm">{{ getNodeShortName(node.num) }}</div>
</div>
</div>
</template>

Expand Down
52 changes: 47 additions & 5 deletions src/components/nodes/NodesList.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
<template>
<div class="flex flex-col w-full overflow-hidden">
<div class="flex flex-col h-full w-full overflow-hidden">

<!-- search -->
<div v-if="nodes.length > 0" class="bg-white p-1 border-b border-gray-300">
<input v-model="nodesSearchTerm" type="text" :placeholder="`Search ${nodes.length} Nodes...`" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>

<!-- nodes -->
<div class="overflow-y-auto">
<div v-for="node of searchedNodes" @click="onNodeClick(node)" class="flex cursor-pointer p-2 border-l-2" :class="[ selectedNodeId === node.num ? 'bg-gray-100 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-200']">
<div class="h-full overflow-y-auto">
<div :key="node.num" v-for="node of searchedNodes" @click="onNodeClick(node)" class="flex cursor-pointer p-2 border-l-2" :class="[ selectedNodeId === node.num ? 'bg-gray-100 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-200']">

<!-- icon -->
<NodeIcon :node="node" class="my-auto mr-2"/>

<!-- name and info -->
<div class="mr-2">
<div class="mr-auto">
<div>{{ getNodeLongName(node.num) }}</div>
<div class="text-sm text-gray-500">

Expand All @@ -40,6 +40,33 @@
</div>
</div>

<!-- drop down menu -->
<DropDownMenu>
<template v-slot:button>
<IconButton v-if="node" class="mx-2 bg-transparent text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</IconButton>
</template>
<template v-slot:items>
<div class="p-2">
<div class="text-sm text-gray-500 font-semibold">{{ getNodeLongName(node.num) }}</div>
<div class="text-sm text-gray-500">{{ getNodeHexId(node.num) }}</div>
</div>
<DropDownMenuItem @click="onDeleteNode(node)">
<template v-slot:icon>
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
</svg>
</template>
<template v-slot:label>
<span class="text-red-500">Forget Node</span>
</template>
</DropDownMenuItem>
</template>
</DropDownMenu>

</div>
</div>

Expand All @@ -51,10 +78,14 @@ import NodeUtils from "../../js/NodeUtils.js";
import moment from "moment";
import NodeIcon from "./NodeIcon.vue";
import GlobalState from "../../js/GlobalState.js";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
import NodeAPI from "../../js/NodeAPI.js";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default {
name: 'NodesList',
components: {NodeIcon},
components: {DropDownMenuItem, DropDownMenu, IconButton, NodeIcon},
emits: [
"node-click",
],
Expand All @@ -79,6 +110,17 @@ export default {
formatUnixSecondsAgo(unixSeconds) {
return moment.unix(unixSeconds).fromNow();
},
async onDeleteNode(node) {
// confirm user wants to remove this node
if(!confirm("Are you sure you want to forget this node?")){
return;
}
// remove node
await NodeAPI.removeNodeByNum(node.num);
},
},
computed: {
GlobalState() {
Expand Down
2 changes: 2 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import { createRouter, createWebHashHistory } from 'vue-router';
import vClickOutside from "click-outside-vue3";
import "./style.css";

import App from './components/App.vue';
Expand Down Expand Up @@ -41,6 +42,7 @@ const router = createRouter({

createApp(App)
.use(router)
.use(vClickOutside)
.mount('#app');

// disconnect before unloading page (chrome webview on android was crashing without this...)
Expand Down

0 comments on commit e8b2ce3

Please sign in to comment.