Skip to content

Commit 7426420

Browse files
authored
Merge pull request #60358 from nextcloud/fix/59888/waffle-menu-p2-polish
feat(core): app menu polish for NC34
2 parents 7a62c35 + c1a835c commit 7426420

4 files changed

Lines changed: 161 additions & 9 deletions

File tree

core/src/components/AppMenu.vue

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
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"
@@ -49,13 +49,14 @@
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'
8283
import AppItem from './AppItem.vue'
8384
import logger from '../logger.js'
8485
86+
// Settings IDs that represent actions, not navigable pages.
87+
const SETTINGS_ACTION_IDS = new Set(['logout'])
88+
8589
export 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;

core/src/tests/components/AppMenu.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,64 @@ describe('core: AppMenu', () => {
165165
const currentApp = wrapper.get('.app-menu__current-app').element
166166
expect(wrapper.vm.returnFocusTarget()).toBe(currentApp)
167167
})
168+
169+
it('falls back to the active settings entry when no app is active', () => {
170+
// Mimics being on /settings/admin/* where the active entry is registered
171+
// as type=settings (NavigationManager) and excluded from the `apps` list.
172+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
173+
if (key === 'apps') {
174+
return [makeApp({ id: 'files', name: 'Files', active: false })]
175+
}
176+
if (key === 'settingsNavEntries') {
177+
// Object keyed by entry id — matches PHP's serialization shape
178+
// (TemplateLayout ships the filtered associative array as-is).
179+
return {
180+
admin_settings: makeApp({
181+
id: 'admin_settings',
182+
name: 'Administration settings',
183+
type: 'settings',
184+
href: '/settings/admin/overview',
185+
icon: '/settings/img/admin.svg',
186+
active: true,
187+
}),
188+
}
189+
}
190+
return fallback
191+
})
192+
const wrapper = mount(AppMenu, { attachTo: document.body })
193+
expect(wrapper.find('.app-menu__current-app').exists()).toBe(true)
194+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Administration settings')
195+
})
196+
197+
it('prefers the active app over a settings entry when both are marked active', () => {
198+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
199+
if (key === 'apps') {
200+
return [makeApp({ id: 'files', name: 'Files', active: true })]
201+
}
202+
if (key === 'settingsNavEntries') {
203+
return { admin_settings: makeApp({ id: 'admin_settings', name: 'Administration settings', type: 'settings', active: true }) }
204+
}
205+
return fallback
206+
})
207+
const wrapper = mount(AppMenu, { attachTo: document.body })
208+
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Files')
209+
})
210+
211+
it('does not render the current-app button when only the logout entry is active', () => {
212+
// Defensive: logout is an action, not a page, so it should never be the
213+
// "current section" even though it carries type=settings. NavigationManager
214+
// today never marks it active, but a future regression shouldn't leak a
215+
// "Log out" label into the header.
216+
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
217+
if (key === 'apps') {
218+
return [makeApp({ id: 'files', name: 'Files', active: false })]
219+
}
220+
if (key === 'settingsNavEntries') {
221+
return { logout: makeApp({ id: 'logout', name: 'Log out', type: 'settings', href: '/logout', active: true }) }
222+
}
223+
return fallback
224+
})
225+
const wrapper = mount(AppMenu, { attachTo: document.body })
226+
expect(wrapper.find('.app-menu__current-app').exists()).toBe(false)
227+
})
168228
})

dist/core-main.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-main.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)