diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue index e7aef02d42b35..f2e887620dc2c 100644 --- a/core/src/components/AppMenu.vue +++ b/core/src/components/AppMenu.vue @@ -5,73 +5,83 @@ @@ -94,6 +104,12 @@ import logger from '../logger.js' // Settings IDs that represent actions, not navigable pages. const SETTINGS_ACTION_IDS = new Set(['logout']) +// Hover delays: open on intent, close late enough to reach the popover. +const HOVER_OPEN_DELAY = 150 +const HOVER_CLOSE_DELAY = 300 +// Ignore a trigger click this long after a hover-open, so it does not close again. +const HOVER_CLICK_GRACE = 500 + export default defineComponent({ name: 'AppMenu', @@ -125,10 +141,16 @@ export default defineComponent({ isAdmin: getCurrentUser()?.isAdmin ?? false, // Roving tabindex: only this tile has tabindex=0; arrow keys move it. focusedIndex: 0, - // NcPopover's focus-trap only knows the slot trigger (waffle). - // The current-app button lives outside the slot, so we track the - // source and restore focus manually via setReturnFocus. + // Which button opened the menu, so focus returns to it. openedFrom: null as 'waffle' | 'currentApp' | null, + // Hover intent timers (see HOVER_OPEN_DELAY / HOVER_CLOSE_DELAY). + openTimer: null as ReturnType | null, + closeTimer: null as ReturnType | null, + // Opened by hover: run without the focus trap, so focus is not stolen. + hoverOpen: false, + // Grace window where a trigger click does not close the menu. + suppressCloseClick: false, + suppressClickTimer: null as ReturnType | null, // Synthetic tile appended to the grid: admins jump to the local // app management page; everyone else lands on apps.nextcloud.com // (external, opens in a new tab via the per-tile newTab flag). @@ -162,6 +184,13 @@ export default defineComponent({ }, computed: { + // Only pass noFocusTrap for hover opens; clicks and keyboard keep the trap. + popoverAttrs(): Record { + return this.hoverOpen + ? { autoHide: this.autoHideCheck, noFocusTrap: true } + : { autoHide: this.autoHideCheck } + }, + currentApp(): INavigationEntry | undefined { // Fall back to the active settings entry on admin pages where no // app is active. @@ -206,6 +235,10 @@ export default defineComponent({ if (isOpen) { this.focusedIndex = this.activeGridIndex() this.tryRecomputeGridMaxHeight(5) + } else { + // Closed again: end any pending click-grace window. + this.clearSuppressClickTimer() + this.suppressCloseClick = false } }, }, @@ -221,6 +254,9 @@ export default defineComponent({ }, beforeUnmount() { + this.clearOpenTimer() + this.clearCloseTimer() + this.clearSuppressClickTimer() unsubscribe('nextcloud:app-menu.refresh', this.setApps) ;(this.$refs.popover as { $off: (e: string, fn: () => void) => void } | undefined)?.$off('after-hide', this.onPopoverAfterHide) }, @@ -236,15 +272,92 @@ export default defineComponent({ : this.$el.querySelector('.app-menu__waffle') }, + // Blocks the popover's outside-click close during the grace window. + autoHideCheck(): boolean { + return !this.suppressCloseClick + }, + onPopoverAfterHide() { this.openedFrom = null + this.hoverOpen = false }, onTriggerClick(source: 'waffle' | 'currentApp') { + // Drop pending hover timers so they don't undo this toggle. + this.clearOpenTimer() + this.clearCloseTimer() + // Ignore the click that would close what hover just opened. + if (this.opened && this.suppressCloseClick) { + return + } + // Explicit click: keep the focus trap. + this.hoverOpen = false this.openedFrom = source this.opened = !this.opened }, + // Hover-to-open, mouse only so keyboard focus never triggers it. + onTriggerPointerEnter(source: 'waffle' | 'currentApp' = 'waffle') { + this.clearCloseTimer() + if (this.opened) { + return + } + this.clearOpenTimer() + this.openTimer = setTimeout(() => { + this.openTimer = null + this.openedFrom = source + this.hoverOpen = true + this.opened = true + // Start the grace window in which a habitual click won't close it. + this.suppressCloseClick = true + this.clearSuppressClickTimer() + this.suppressClickTimer = setTimeout(() => { + this.suppressClickTimer = null + this.suppressCloseClick = false + }, HOVER_CLICK_GRACE) + }, HOVER_OPEN_DELAY) + }, + + // Cursor left: cancel a pending open, schedule the close. + onPointerLeave() { + this.clearOpenTimer() + this.scheduleClose() + }, + + // Cursor moved into the open popover: keep it open. + onPopoverPointerEnter() { + this.clearCloseTimer() + }, + + scheduleClose() { + this.clearCloseTimer() + this.closeTimer = setTimeout(() => { + this.closeTimer = null + this.opened = false + }, HOVER_CLOSE_DELAY) + }, + + clearOpenTimer() { + if (this.openTimer !== null) { + clearTimeout(this.openTimer) + this.openTimer = null + } + }, + + clearCloseTimer() { + if (this.closeTimer !== null) { + clearTimeout(this.closeTimer) + this.closeTimer = null + } + }, + + clearSuppressClickTimer() { + if (this.suppressClickTimer !== null) { + clearTimeout(this.suppressClickTimer) + this.suppressClickTimer = null + } + }, + setNavigationCounter(id: string, counter: number) { const app = this.appList.find(({ app }) => app === id) if (app) { @@ -397,56 +510,83 @@ export default defineComponent({ display: flex; align-items: center; - &__waffle { - // NcButton's tertiary-no-background variant uses --color-main-text, - // which is dark on light themes. The header sits on the theme primary - // background, so override to use the matching plain-text color. - --color-main-text: var(--color-background-plain-text); - color: var(--color-background-plain-text); - - // Class merges onto NcButton's root