Skip to content

Commit 875bf27

Browse files
committed
feat(core): open the app menu on hover
Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com>
1 parent 98c0a5a commit 875bf27

2 files changed

Lines changed: 334 additions & 9 deletions

File tree

core/src/components/AppMenu.vue

Lines changed: 163 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55

66
<template>
77
<nav class="app-menu" :aria-label="t('core', 'Applications')">
8-
<!-- One wrapper so both triggers act as a single control, sharing one
9-
highlight. On narrow screens only the waffle shows. -->
8+
<!-- One wrapper so both triggers act as a single control: a shared
9+
highlight and hover-to-open across the whole area. On narrow screens
10+
only the waffle shows. -->
1011
<div
1112
class="app-menu__trigger"
12-
:class="{ 'app-menu__trigger--open': opened }">
13+
:class="{ 'app-menu__trigger--open': opened }"
14+
@mouseenter="onTriggerPointerEnter"
15+
@mouseleave="onPointerLeave">
1316
<NcPopover
1417
ref="popover"
1518
:shown="opened"
1619
:triggers="[]"
20+
v-bind="{ autoHide: autoHideCheck }"
1721
placement="bottom-start"
1822
:skidding="popoverSkidding"
1923
:set-return-focus="returnFocusTarget"
@@ -27,7 +31,7 @@
2731
:aria-label="t('core', 'Open apps menu')"
2832
aria-haspopup="menu"
2933
:aria-expanded="opened ? 'true' : 'false'"
30-
@click="onTriggerClick('waffle')">
34+
@click="onTriggerClick('waffle', $event)">
3135
<template #icon>
3236
<IconDotsGrid :size="20" />
3337
</template>
@@ -37,8 +41,14 @@
3741
<div
3842
class="app-menu__popover"
3943
role="menu"
40-
:aria-label="t('core', 'Apps')">
41-
<div ref="grid" class="app-menu__grid" @keydown="onGridKeydown">
44+
:aria-label="t('core', 'Apps')"
45+
@mouseenter="onPopoverPointerEnter"
46+
@mouseleave="onPointerLeave">
47+
<div
48+
ref="grid"
49+
class="app-menu__grid"
50+
:class="{ 'app-menu__grid--suppress-focus-ring': suppressGridFocusRing }"
51+
@keydown="onGridKeydown">
4252
<AppItem
4353
v-for="(item, i) in gridItems"
4454
:key="item.id"
@@ -57,7 +67,7 @@
5767
:aria-label="currentAppLabel"
5868
aria-haspopup="menu"
5969
:aria-expanded="opened ? 'true' : 'false'"
60-
@click="onTriggerClick('currentApp')">
70+
@click="onTriggerClick('currentApp', $event)">
6171
<template #icon>
6272
<!-- Settings sub-sections share one generic cog. An inline MDI icon
6373
inherits the button's currentColor (--color-background-plain-text),
@@ -100,6 +110,15 @@ import logger from '../logger.js'
100110
// Settings IDs that represent actions, not navigable pages.
101111
const SETTINGS_ACTION_IDS = new Set(['logout'])
102112
113+
// Delay before a hover opens the menu, long enough to ignore a passing cursor.
114+
const HOVER_OPEN_DELAY = 150
115+
// Delay before closing after the cursor leaves, so moving onto the popover
116+
// doesn't dismiss it.
117+
const HOVER_CLOSE_DELAY = 300
118+
// After a hover-open, briefly ignore a trigger click so a habitual click-to-open
119+
// doesn't immediately close the menu.
120+
const HOVER_CLICK_GRACE = 500
121+
103122
export default defineComponent({
104123
name: 'AppMenu',
105124
@@ -135,6 +154,19 @@ export default defineComponent({
135154
// The current-app button lives outside the slot, so we track the
136155
// source and restore focus manually via setReturnFocus.
137156
openedFrom: null as 'waffle' | 'currentApp' | null,
157+
// Hover intent timers (see HOVER_OPEN_DELAY / HOVER_CLOSE_DELAY).
158+
openTimer: null as ReturnType<typeof setTimeout> | null,
159+
closeTimer: null as ReturnType<typeof setTimeout> | null,
160+
// Menu closed by a pointer (hover-out or mouse click). returnFocusTarget()
161+
// then skips restoring focus, so no focus ring flashes on the trigger.
162+
closedByPointer: false,
163+
// Grace window after a hover-open where a trigger click is ignored
164+
// instead of closing the menu.
165+
suppressCloseClick: false,
166+
suppressClickTimer: null as ReturnType<typeof setTimeout> | null,
167+
// Hide the active tile's focus ring on pointer opens (set here, cleared
168+
// on keyboard grid navigation). Keyboard opens still show it, like master.
169+
suppressGridFocusRing: false,
138170
// Synthetic tile appended to the grid: admins jump to the local
139171
// app management page; everyone else lands on apps.nextcloud.com
140172
// (external, opens in a new tab via the per-tile newTab flag).
@@ -212,6 +244,10 @@ export default defineComponent({
212244
if (isOpen) {
213245
this.focusedIndex = this.activeGridIndex()
214246
this.tryRecomputeGridMaxHeight(5)
247+
} else {
248+
// Closed again: end any pending click-grace window.
249+
this.clearSuppressClickTimer()
250+
this.suppressCloseClick = false
215251
}
216252
},
217253
},
@@ -227,6 +263,9 @@ export default defineComponent({
227263
},
228264
229265
beforeUnmount() {
266+
this.clearOpenTimer()
267+
this.clearCloseTimer()
268+
this.clearSuppressClickTimer()
230269
unsubscribe('nextcloud:app-menu.refresh', this.setApps)
231270
;(this.$refs.popover as { $off: (e: string, fn: () => void) => void } | undefined)?.$off('after-hide', this.onPopoverAfterHide)
232271
},
@@ -236,19 +275,120 @@ export default defineComponent({
236275
// slot trigger (waffle); we override so current-app opens return
237276
// there instead. Waffle is the fallback since current-app only
238277
// renders when an app is active.
239-
returnFocusTarget(): HTMLElement | null {
278+
returnFocusTarget(): HTMLElement | false | null {
279+
// Pointer close: return false so the focus-trap leaves focus alone.
280+
// Restoring it to the trigger would flash the focus ring. Keyboard
281+
// closes still restore focus and show the ring.
282+
if (this.closedByPointer) {
283+
return false
284+
}
240285
return this.openedFrom === 'currentApp'
241286
? this.$el.querySelector('.app-menu__current-app')
242287
: this.$el.querySelector('.app-menu__waffle')
243288
},
244289
290+
// autoHide for the floating-ui popover. It closes on any click outside the
291+
// teleported content, including the trigger. Return false during the grace
292+
// window so a habitual trigger click doesn't close the menu.
293+
autoHideCheck(): boolean {
294+
return !this.suppressCloseClick
295+
},
296+
245297
onPopoverAfterHide() {
298+
// Drop focus left on a trigger after a pointer close, so no ring lingers.
299+
if (this.closedByPointer && this.$el.contains(document.activeElement)) {
300+
(document.activeElement as HTMLElement).blur()
301+
}
302+
this.closedByPointer = false
246303
this.openedFrom = null
247304
},
248305
249-
onTriggerClick(source: 'waffle' | 'currentApp') {
306+
onTriggerClick(source: 'waffle' | 'currentApp', event?: MouseEvent) {
307+
// Drop pending hover timers so they don't undo this toggle.
308+
this.clearOpenTimer()
309+
this.clearCloseTimer()
310+
// During the grace window, ignore a click that would close a menu that
311+
// hover just opened.
312+
if (this.opened && this.suppressCloseClick) {
313+
return
314+
}
250315
this.openedFrom = source
251316
this.opened = !this.opened
317+
if (this.opened) {
318+
// Mouse click (detail > 0) is a pointer open: hide the tile focus
319+
// ring. Keyboard (detail 0) shows it.
320+
this.suppressGridFocusRing = (event?.detail ?? 0) > 0
321+
} else if ((event?.detail ?? 0) > 0) {
322+
// Mouse click that closed the menu: don't leave a focus ring behind.
323+
this.closedByPointer = true
324+
}
325+
},
326+
327+
// Hover-to-open (mouse only, never focus) after a short delay. Bound on the
328+
// wrapper, so it has no specific source; the waffle is the return target.
329+
onTriggerPointerEnter(source: 'waffle' | 'currentApp' = 'waffle') {
330+
this.clearCloseTimer()
331+
if (this.opened) {
332+
return
333+
}
334+
this.clearOpenTimer()
335+
this.openTimer = setTimeout(() => {
336+
this.openTimer = null
337+
this.openedFrom = source
338+
this.opened = true
339+
// Hover is a pointer open: don't flash the active tile's focus ring.
340+
this.suppressGridFocusRing = true
341+
// Start the grace window in which a habitual click won't close it.
342+
this.suppressCloseClick = true
343+
this.clearSuppressClickTimer()
344+
this.suppressClickTimer = setTimeout(() => {
345+
this.suppressClickTimer = null
346+
this.suppressCloseClick = false
347+
}, HOVER_CLICK_GRACE)
348+
}, HOVER_OPEN_DELAY)
349+
},
350+
351+
// Cursor left a trigger or the popover: cancel a pending open and start
352+
// the close grace period.
353+
onPointerLeave() {
354+
this.clearOpenTimer()
355+
this.scheduleClose()
356+
},
357+
358+
// Cursor moved into the open popover: keep it open.
359+
onPopoverPointerEnter() {
360+
this.clearCloseTimer()
361+
},
362+
363+
scheduleClose() {
364+
this.clearCloseTimer()
365+
this.closeTimer = setTimeout(() => {
366+
this.closeTimer = null
367+
// Hover-out is a pointer close: suppress the returned-focus ring.
368+
this.closedByPointer = true
369+
this.opened = false
370+
}, HOVER_CLOSE_DELAY)
371+
},
372+
373+
clearOpenTimer() {
374+
if (this.openTimer !== null) {
375+
clearTimeout(this.openTimer)
376+
this.openTimer = null
377+
}
378+
},
379+
380+
clearCloseTimer() {
381+
if (this.closeTimer !== null) {
382+
clearTimeout(this.closeTimer)
383+
this.closeTimer = null
384+
}
385+
},
386+
387+
clearSuppressClickTimer() {
388+
if (this.suppressClickTimer !== null) {
389+
clearTimeout(this.suppressClickTimer)
390+
this.suppressClickTimer = null
391+
}
252392
},
253393
254394
setNavigationCounter(id: string, counter: number) {
@@ -326,6 +466,9 @@ export default defineComponent({
326466
return
327467
}
328468
469+
// Keyboard navigation: reveal the tile focus ring a pointer open hid.
470+
this.suppressGridFocusRing = false
471+
329472
const cols = 4
330473
const total = this.gridItems.length
331474
const i = this.focusedIndex
@@ -547,6 +690,17 @@ export default defineComponent({
547690
// data-attrs don't reach ::-webkit-scrollbar pseudo-elements in Chrome.
548691
scrollbar-width: thin;
549692
scrollbar-color: var(--color-scrollbar) transparent;
693+
694+
// On a pointer open the active tile is focused but shouldn't flash its ring
695+
// — the bold label already marks it. Removed once the user navigates by
696+
// keyboard. :deep reaches into the AppItem child.
697+
&--suppress-focus-ring :deep(.app-item:focus-visible) {
698+
box-shadow: none;
699+
700+
&:not(:hover) {
701+
background-color: transparent;
702+
}
703+
}
550704
}
551705
}
552706
</style>

0 commit comments

Comments
 (0)