3333 class =" app-menu__popover"
3434 role =" menu"
3535 :aria-label =" t('core', 'Apps')" >
36- <div class =" app-menu__grid" @keydown =" onGridKeydown" >
36+ <div ref = " grid " class =" app-menu__grid" @keydown =" onGridKeydown" >
3737 <AppItem
3838 v-for =" (item, i) in gridItems"
3939 :key =" item.id"
4949 v-if =" currentApp"
5050 class =" app-menu__current-app"
5151 variant =" tertiary-no-background"
52- :aria-label =" t('core', 'Open apps menu') "
52+ :aria-label =" currentAppLabel "
5353 aria-haspopup =" menu"
5454 :aria-expanded =" opened ? 'true' : 'false'"
5555 @click =" onTriggerClick('currentApp')" >
5656 <template #icon >
5757 <img
5858 class =" app-menu__current-app-icon"
59+ :class =" { 'app-menu__current-app-icon--settings': currentApp.type === 'settings' }"
5960 :src =" currentApp.icon"
6061 alt =" "
6162 aria-hidden =" true" >
@@ -82,6 +83,9 @@ import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
8283import AppItem from ' ./AppItem.vue'
8384import logger from ' ../logger.js'
8485
86+ // Settings IDs that represent actions, not navigable pages.
87+ const SETTINGS_ACTION_IDS = new Set ([' logout' ])
88+
8589export default defineComponent ({
8690 name: ' AppMenu' ,
8791
@@ -103,8 +107,12 @@ export default defineComponent({
103107
104108 data() {
105109 const appList = loadState <INavigationEntry []>(' core' , ' apps' , [])
110+ // Record<id, entry>, not an array: PHP ships getAll('settings') without
111+ // array_values(). Matches AccountMenu.vue's usage.
112+ const settingsList = loadState <Record <string , INavigationEntry >>(' core' , ' settingsNavEntries' , {})
106113 return {
107114 appList ,
115+ settingsList ,
108116 isAdmin: getCurrentUser ()?.isAdmin ?? false ,
109117 // Roving tabindex: only this tile has tabindex=0; arrow keys move it.
110118 focusedIndex: 0 ,
@@ -146,7 +154,18 @@ export default defineComponent({
146154
147155 computed: {
148156 currentApp(): INavigationEntry | undefined {
157+ // Fall back to the active settings entry on admin pages where no
158+ // app is active.
149159 return this .appList .find ((app ) => app .active )
160+ ?? Object .values (this .settingsList ).find ((entry ) => entry .active && ! SETTINGS_ACTION_IDS .has (entry .id ))
161+ },
162+
163+ // aria-label overrides the inner span text, so the section name
164+ // has to be duplicated here for screen readers.
165+ currentAppLabel(): string {
166+ return this .currentApp
167+ ? t (' core' , ' Open apps menu, currently in {app}' , { app: this .currentApp .name })
168+ : t (' core' , ' Open apps menu' )
150169 },
151170
152171 // Stable-ordered list that focusedIndex indexes into. The trailing
@@ -159,10 +178,13 @@ export default defineComponent({
159178 },
160179
161180 watch: {
162- // On open, land the roving stop on the active app rather than index 0.
181+ // On open, land the roving stop on the active app rather than index 0
182+ // and measure the grid as soon as it mounts (before the open
183+ // transition finishes, so the cap is set without a flash).
163184 opened(isOpen : boolean ) {
164185 if (isOpen ) {
165186 this .focusedIndex = this .activeGridIndex ()
187+ this .tryRecomputeGridMaxHeight (5 )
166188 }
167189 },
168190 },
@@ -218,6 +240,43 @@ export default defineComponent({
218240 }
219241 },
220242
243+ // Poll briefly for the grid ref (NcPopover renders the slot async)
244+ // then measure once. Bounded so a missing ref can never leak frames.
245+ tryRecomputeGridMaxHeight(retries : number ) {
246+ if (! this .opened || retries <= 0 ) {
247+ return
248+ }
249+ if (! this .$refs .grid ) {
250+ requestAnimationFrame (() => this .tryRecomputeGridMaxHeight (retries - 1 ))
251+ return
252+ }
253+ this .recomputeGridMaxHeight ()
254+ },
255+
256+ // Cap = sum of first 6 row heights + baseline × 6, so the peek of
257+ // row 7 stays constant when wraps grow rows.
258+ recomputeGridMaxHeight() {
259+ const grid = this .$refs .grid as HTMLElement | undefined
260+ if (! grid ) {
261+ return
262+ }
263+ const VISIBLE_CELLS = 24 // 4 cols × 6 visible rows
264+ const cells = grid .children
265+ if (cells .length <= VISIBLE_CELLS ) {
266+ grid .style .maxHeight = ' '
267+ return
268+ }
269+ const firstHidden = cells [VISIBLE_CELLS ] as HTMLElement | undefined
270+ const firstCell = cells [0 ] as HTMLElement | undefined
271+ if (! firstHidden || ! firstCell ) {
272+ return
273+ }
274+ const sumOfFirstRows = firstHidden .getBoundingClientRect ().top
275+ - firstCell .getBoundingClientRect ().top
276+ const baseline = parseFloat (getComputedStyle (grid ).getPropertyValue (' --default-grid-baseline' )) || 4
277+ grid .style .maxHeight = ` ${sumOfFirstRows + baseline * 6 }px `
278+ },
279+
221280 // Index of the active app within `gridItems`, or 0 if none is active.
222281 activeGridIndex(): number {
223282 const idx = this .gridItems .findIndex ((app ) => app .active )
@@ -366,6 +425,16 @@ export default defineComponent({
366425 outline : none !important ;
367426 box-shadow : inset 0 0 0 2px var (--color-background-plain-text ) !important ;
368427 }
428+
429+ // Inner text slot needs min-width: 0 so the label can ellipsize.
430+ :deep (.button-vue__text ) {
431+ min-width : 0 ;
432+ }
433+
434+ // Hide on small screens (matches $breakpoint-small-mobile in @nextcloud/vue).
435+ @media only screen and (max-width : 512px ) {
436+ display : none !important ;
437+ }
369438 }
370439
371440 & __current-app-icon {
@@ -374,13 +443,27 @@ export default defineComponent({
374443 // Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
375444 filter : var (--background-image-invert-if-bright );
376445 mask : var (--header-menu-icon-mask );
446+
447+ // Settings icons ship dark (designed for the white settings sidebar);
448+ // force-white so they read against the themed header.
449+ & --settings {
450+ filter : brightness (0 ) invert (1 );
451+ }
377452 }
378453
379454 & __current-app-name {
455+ // inline-block: inline elements ignore max-width + overflow.
456+ display : inline-block ;
457+ vertical-align : middle ;
380458 font-size : var (--default-font-size );
381459 font-weight : 500 ;
382460 white-space : nowrap ;
383461 letter-spacing : -0.5px ;
462+ overflow : hidden ;
463+ text-overflow : ellipsis ;
464+ // Cap width so long titles ellipsize instead of pushing the header
465+ // icons off-screen (.header-start doesn't shrink).
466+ max-width : clamp (80px , 22vw , 320px );
384467 }
385468
386469 & __popover {
@@ -391,14 +474,23 @@ export default defineComponent({
391474 & __grid {
392475 --app-item-col-width : 69px ;
393476 --app-item-row-height : 64px ;
394- --app-menu-rows-visible : 6 ;
395- padding : calc (var (--default-grid-baseline ) * 3 ) calc (var (--default-grid-baseline ) * 2 );
477+ // border-box: the JS-set max-height (see recomputeGridMaxHeight)
478+ // needs to include padding for the peek math to hold.
479+ box-sizing : border-box ;
480+ padding : calc (var (--default-grid-baseline ) * 2 );
396481 display : grid ;
397482 grid-template-columns : repeat (4 , var (--app-item-col-width ));
398483 grid-auto-rows : minmax (var (--app-item-row-height ), max-content );
399- max-height : calc ( var ( --app-item-row-height ) * var ( --app-menu-rows-visible ) + var ( --default-grid-baseline ) * 5 );
484+ // max-height set inline by recomputeGridMaxHeight(); CSS just owns the scroll.
400485 overflow-y : auto ;
401486
487+ // Extra top padding on first-row tiles so the hover bg reads
488+ // concentric with the popover's rounded top corner. !important
489+ // because AppItem's scoped rule has the same specificity.
490+ > :nth-child (-n +4 ) {
491+ padding-block-start : calc (var (--default-grid-baseline ) * 2 ) !important ;
492+ }
493+
402494 // WebKit equivalents are in the unscoped block below: scoped CSS
403495 // data-attrs don't reach ::-webkit-scrollbar pseudo-elements in Chrome.
404496 scrollbar-width : thin ;
0 commit comments