archy/neode-ui/src/components/AppLauncherOverlay.vue
Dorian d0312c6721 Enhance AppLauncherOverlay and navigation logic for improved user experience
- Added functionality to close the overlay and return focus to the launcher when the Escape key is pressed inside an iframe.
- Implemented message handling to close the app launcher from the parent window.
- Updated navigation logic in useControllerNav to improve focus management when navigating between sidebar and main content.
- Enhanced Dashboard and Settings views with data attributes for better controller navigation support.
2026-02-18 11:29:05 +00:00

177 lines
5.8 KiB
Vue

<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Panel - inset with margins, glass style like spotlight -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl"
:class="panelClasses"
>
<!-- Header bar - drag handle + title + close -->
<div class="flex items-center gap-3 border-b border-white/10 px-4 py-3">
<div class="flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ store.title || 'App' }}</span>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors disabled:opacity-70"
aria-label="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5 transition-transform duration-300"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
ref="closeBtnRef"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<iframe
ref="iframeRef"
v-if="store.url"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onIframeLoad"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
function refreshIframe() {
isRefreshing.value = true
iframeRefreshKey.value++
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
}
function injectScrollbarHideIfSameOrigin() {
try {
const doc = iframeRef.value?.contentDocument
if (!doc) return
const style = doc.createElement('style')
style.textContent = `
* { -ms-overflow-style: none; scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }
`
doc.head.appendChild(style)
// Escape from inside iframe → close overlay and return focus to launcher
doc.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
e.preventDefault()
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
}
})
} catch {
/* Cross-origin: cannot access iframe document */
}
}
const panelClasses = [
'glass-card',
'w-full h-full max-w-[calc(100vw-3rem)] max-h-[calc(100vh-5rem)]',
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
]
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
function onMessage(e: MessageEvent) {
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
store.close()
}
}
watch(
() => store.isOpen,
(open) => {
if (open) {
closeBtnRef.value?.focus()
} else {
isRefreshing.value = false
}
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
})
</script>
<style scoped>
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
}
.app-launcher-enter-active .app-launcher-panel,
.app-launcher-leave-active .app-launcher-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.app-launcher-enter-from,
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .app-launcher-panel,
.app-launcher-leave-to .app-launcher-panel {
transform: scale(0.96);
opacity: 0;
}
</style>