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 "
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 >
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 "
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.
101111const 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+
103122export 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