From ed41e995c07e8f12cd4ad255c3e5b7492eb27ca3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 10 Aug 2026 22:12:16 +0200 Subject: [PATCH 1/2] feat(NavigationManager): allow defining navigation actions Signed-off-by: Ferdinand Thiessen --- core/Controller/NavigationController.php | 5 ++- core/ResponseDefinitions.php | 16 ++++++++- core/src/types/navigation.d.ts | 6 ++-- lib/private/TemplateLayout.php | 1 + lib/public/INavigationManager.php | 41 +++++++++++++++++------- tests/lib/NavigationManagerTest.php | 35 ++++++++++++++++---- 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/core/Controller/NavigationController.php b/core/Controller/NavigationController.php index 3b667137e7f15..2e8fc269751ed 100644 --- a/core/Controller/NavigationController.php +++ b/core/Controller/NavigationController.php @@ -20,6 +20,7 @@ /** * @psalm-import-type CoreNavigationEntry from ResponseDefinitions + * @psalm-import-type CoreNavigationSettingsEntry from ResponseDefinitions */ class NavigationController extends OCSController { public function __construct( @@ -48,6 +49,7 @@ public function getAppsNavigation(bool $absolute = false): DataResponse { if ($absolute) { $navigation = $this->rewriteToAbsoluteUrls($navigation); } + /** @var list $navigation */ $navigation = array_values($navigation); $response = new DataResponse($navigation); $response->setETag($this->generateETag($navigation)); @@ -58,7 +60,7 @@ public function getAppsNavigation(bool $absolute = false): DataResponse { * Get the settings navigation * * @param bool $absolute Rewrite URLs to absolute ones - * @return DataResponse, array{}>|DataResponse, array{}> + * @return DataResponse, array{}>|DataResponse, array{}> * * 200: Apps navigation returned * 304: No apps navigation changed @@ -71,6 +73,7 @@ public function getSettingsNavigation(bool $absolute = false): DataResponse { if ($absolute) { $navigation = $this->rewriteToAbsoluteUrls($navigation); } + /** @var list $navigation */ $navigation = array_values($navigation); $response = new DataResponse($navigation); $response->setETag($this->generateETag($navigation)); diff --git a/core/ResponseDefinitions.php b/core/ResponseDefinitions.php index b2be2ecfba1cb..e184daf16b973 100644 --- a/core/ResponseDefinitions.php +++ b/core/ResponseDefinitions.php @@ -29,7 +29,21 @@ * order?: int, * href: string, * icon: string, - * type: string, + * type: 'link', + * name: string, + * app?: string, + * default?: bool, + * active: bool, + * classes: string, + * unread: int, + * } + * + * @psalm-type CoreNavigationSettingsEntry = array{ + * id: string, + * order?: int, + * href: string, + * icon: string, + * type: 'settings', * name: string, * app?: string, * default?: bool, diff --git a/core/src/types/navigation.d.ts b/core/src/types/navigation.d.ts index 5698aab205ea6..33110f63df738 100644 --- a/core/src/types/navigation.d.ts +++ b/core/src/types/navigation.d.ts @@ -15,8 +15,10 @@ export interface INavigationEntry { href: string /** The icon used for the naviation entry */ icon: string - /** Type of the navigation entry ('link' vs 'settings') */ - type: 'link' | 'settings' + /** CSS color of the action indicator, only used by entries of type 'action' */ + color?: string + /** Type of the navigation entry ('link' vs 'settings' vs 'action') */ + type: 'link' | 'settings' | 'action' /** Localized name of the navigation entry */ name: string /** Whether this is the default app */ diff --git a/lib/private/TemplateLayout.php b/lib/private/TemplateLayout.php index afcda38e64eb4..4836a33560a3b 100644 --- a/lib/private/TemplateLayout.php +++ b/lib/private/TemplateLayout.php @@ -82,6 +82,7 @@ public function getPageTemplate(string $renderAs, string $appId): ITemplate { $this->initialState->provideInitialState('core', 'active-app', $this->navigationManager->getActiveEntry()); $this->initialState->provideInitialState('core', 'apps', array_values($this->navigationManager->getAll())); + $this->initialState->provideInitialState('core', 'navigationActions', array_values($this->navigationManager->getAll(INavigationManager::TYPE_ACTION))); $this->initialState->provideInitialState('unified-search', 'min-search-length', $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UNIFIED_SEARCH_MIN_SEARCH_LENGTH)); if ($this->config->getSystemValueBool('unified_search.enabled', false) || !$this->config->getSystemValueBool('enable_non-accessible_features', true)) { diff --git a/lib/public/INavigationManager.php b/lib/public/INavigationManager.php index 005c35282f416..1dc5fea90c304 100644 --- a/lib/public/INavigationManager.php +++ b/lib/public/INavigationManager.php @@ -18,13 +18,24 @@ * * @since 6.0.0 * - * @psalm-type NavigationEntry = array{id: string, order: int, href: string, name: string, app?: string, icon?: string, classes?: string, type?: string} + * @psalm-type NavigationEntry = array{ + * id: string, + * order: int, + * href: string, + * name: string, + * app?: string, + * icon?: string, + * color?: string, + * classes?: string, + * type?: 'link'|'action'|'settings', + * } * @psalm-type NavigationEntryOutput = array{ * id: string, * order?: int, * href: string, * icon: string, - * type: string, + * color?: string, + * type: 'link'|'action'|'settings', * name: string, * app?: string, * default?: bool, @@ -36,29 +47,35 @@ #[Consumable(since: '6.0.0')] #[ExceptionalImplementable(app: 'guest')] interface INavigationManager { + /** + * All navigation entries + * @since 33.0.0 + */ + public const string TYPE_ALL = 'all'; + + /** + * Navigation entries for actions of the app menu + * @since 35.0.0 + */ + public const string TYPE_ACTION = 'action'; + /** * Navigation entries of the app navigation * @since 16.0.0 */ - public const TYPE_APPS = 'link'; + public const string TYPE_APPS = 'link'; /** * Navigation entries of the settings navigation * @since 16.0.0 */ - public const TYPE_SETTINGS = 'settings'; + public const string TYPE_SETTINGS = 'settings'; /** * Navigation entries for public page footer navigation * @since 16.0.0 */ - public const TYPE_GUEST = 'guest'; - - /** - * All navigation entries - * @since 33.0.0 - */ - public const TYPE_ALL = 'all'; + public const string TYPE_GUEST = 'guest'; /** * Creates a new navigation entry @@ -89,7 +106,7 @@ public function getActiveEntry(): ?string; /** * Get a list of navigation entries * - * @param self::TYPE_APPS|self::TYPE_SETTINGS|self::TYPE_GUEST|self::TYPE_ALL $type type of the navigation entries + * @param self::TYPE_* $type type of the navigation entries * @return array * @since 14.0.0 */ diff --git a/tests/lib/NavigationManagerTest.php b/tests/lib/NavigationManagerTest.php index 8120425655204..42c85243022b1 100644 --- a/tests/lib/NavigationManagerTest.php +++ b/tests/lib/NavigationManagerTest.php @@ -15,6 +15,7 @@ use OCP\IConfig; use OCP\IGroupManager; use OCP\IL10N; +use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserSession; @@ -24,20 +25,20 @@ use Psr\Log\LoggerInterface; class NavigationManagerTest extends TestCase { - /** @var AppManager|\PHPUnit\Framework\MockObject\MockObject */ + /** @var AppManager&MockObject */ protected $appManager; - /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */ + /** @var IURLGenerator&MockObject */ protected $urlGenerator; - /** @var IFactory|\PHPUnit\Framework\MockObject\MockObject */ + /** @var IFactory&MockObject */ protected $l10nFac; - /** @var IUserSession|\PHPUnit\Framework\MockObject\MockObject */ + /** @var IUserSession&MockObject */ protected $userSession; - /** @var IGroupManager|\PHPUnit\Framework\MockObject\MockObject */ + /** @var IGroupManager&MockObject */ protected $groupManager; - /** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */ + /** @var IConfig&MockObject */ protected $config; - protected IEVentDispatcher|MockObject $dispatcher; + protected IEventDispatcher&MockObject $dispatcher; /** @var NavigationManager */ protected $navigationManager; @@ -201,6 +202,26 @@ public function testAddClosureAfterSetup(): void { $this->assertArrayHasKey('late entry', $navigationEntries); } + public function testGetAllFiltersActions(): void { + $this->navigationManager->add([ + 'id' => 'files', + 'name' => 'Files', + 'order' => 1, + 'href' => 'url', + ]); + $this->navigationManager->add([ + 'id' => 'logout', + 'name' => 'Log out', + 'order' => 2, + 'href' => 'url', + 'type' => INavigationManager::TYPE_ACTION, + ]); + + $this->assertEquals(['logout'], array_keys($this->navigationManager->getAll(INavigationManager::TYPE_ACTION))); + $this->assertEquals(['files'], array_keys($this->navigationManager->getAll(INavigationManager::TYPE_APPS))); + $this->assertEquals(['files', 'logout'], array_keys($this->navigationManager->getAll(INavigationManager::TYPE_ALL))); + } + public function testAddArrayClearGetAll(): void { $entry = [ 'id' => 'entry id', From 61b21548b5ce92f77b0911759afd3ac46527e33b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 10 Aug 2026 22:12:48 +0200 Subject: [PATCH 2/2] feat(core): show navigation actions Signed-off-by: Ferdinand Thiessen --- core/src/components/AppActionIcon.vue | 107 ++++++ core/src/components/AppIcon.vue | 8 +- core/src/components/AppMenu.vue | 30 +- core/src/components/AppMenuAction.vue | 64 ++++ core/src/components/AppMenuActions.vue | 353 ++++++++++++++++++ .../{AppItem.vue => AppMenuItem.vue} | 107 ++++-- core/src/eventbus.d.ts | 2 + .../tests/components/AppActionIcon.spec.ts | 38 ++ core/src/tests/components/AppItem.spec.ts | 46 --- core/src/tests/components/AppMenu.spec.ts | 263 ++++++++++++- core/src/tests/components/AppMenuItem.spec.ts | 77 ++++ 11 files changed, 1009 insertions(+), 86 deletions(-) create mode 100644 core/src/components/AppActionIcon.vue create mode 100644 core/src/components/AppMenuAction.vue create mode 100644 core/src/components/AppMenuActions.vue rename core/src/components/{AppItem.vue => AppMenuItem.vue} (63%) create mode 100644 core/src/tests/components/AppActionIcon.spec.ts delete mode 100644 core/src/tests/components/AppItem.spec.ts create mode 100644 core/src/tests/components/AppMenuItem.spec.ts diff --git a/core/src/components/AppActionIcon.vue b/core/src/components/AppActionIcon.vue new file mode 100644 index 0000000000000..1d1b0dc950c83 --- /dev/null +++ b/core/src/components/AppActionIcon.vue @@ -0,0 +1,107 @@ + + + + + + + diff --git a/core/src/components/AppIcon.vue b/core/src/components/AppIcon.vue index 21bbdd15c676d..2827c54b08fbe 100644 --- a/core/src/components/AppIcon.vue +++ b/core/src/components/AppIcon.vue @@ -20,14 +20,13 @@ + + diff --git a/core/src/components/AppMenuActions.vue b/core/src/components/AppMenuActions.vue new file mode 100644 index 0000000000000..f3c54847e9a73 --- /dev/null +++ b/core/src/components/AppMenuActions.vue @@ -0,0 +1,353 @@ + + + + + + + + + + diff --git a/core/src/components/AppItem.vue b/core/src/components/AppMenuItem.vue similarity index 63% rename from core/src/components/AppItem.vue rename to core/src/components/AppMenuItem.vue index f7aec5e7f9db2..ca873d39f6dcd 100644 --- a/core/src/components/AppItem.vue +++ b/core/src/components/AppMenuItem.vue @@ -3,33 +3,6 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - - + + diff --git a/core/src/eventbus.d.ts b/core/src/eventbus.d.ts index a48a096845cb7..63af9ded33311 100644 --- a/core/src/eventbus.d.ts +++ b/core/src/eventbus.d.ts @@ -11,6 +11,8 @@ declare module '@nextcloud/event-bus' { 'nextcloud:unified-search:reset': undefined 'nextcloud:unified-search:search': { query: string } 'nextcloud:app-menu.refresh': { apps: INavigationEntry[] } + /** Id of the activated navigation action that has no `href` to navigate to */ + 'core:navigation-action:clicked': string } } diff --git a/core/src/tests/components/AppActionIcon.spec.ts b/core/src/tests/components/AppActionIcon.spec.ts new file mode 100644 index 0000000000000..706c5e3216d8c --- /dev/null +++ b/core/src/tests/components/AppActionIcon.spec.ts @@ -0,0 +1,38 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AppActionIcon from '../../components/AppActionIcon.vue' + +describe('core: AppActionIcon', () => { + it('paints the icon from its URL', () => { + const wrapper = mount(AppActionIcon, { propsData: { icon: '/core/img/actions/upload.svg' } }) + const icon = wrapper.get('.app-action-icon__img').element as HTMLElement + expect(icon.style.getPropertyValue('--app-action-icon-url')).toBe('url("/core/img/actions/upload.svg")') + }) + + it('escapes the icon URL so it cannot break out of the url() token', () => { + const wrapper = mount(AppActionIcon, { propsData: { icon: '/i".svg' } }) + const icon = wrapper.get('.app-action-icon__img').element as HTMLElement + expect(icon.style.getPropertyValue('--app-action-icon-url')).toBe('url("/i\\".svg")') + }) + + it('renders the indicator only with a color', () => { + const withColor = mount(AppActionIcon, { propsData: { icon: '/i.svg', color: 'rgb(0, 130, 201)' } }) + const indicator = withColor.get('.app-action-icon__indicator').element as HTMLElement + expect(indicator.style.getPropertyValue('--app-action-icon-indicator-color')).toBe('rgb(0, 130, 201)') + expect(indicator.querySelector('.plus-icon')).toBeTruthy() + + const withoutColor = mount(AppActionIcon, { propsData: { icon: '/i.svg' } }) + expect(withoutColor.find('.app-action-icon__indicator').exists()).toBe(false) + }) + + it('renders slotted icon components instead of the URL', () => { + const wrapper = mount(AppActionIcon, { slots: { default: '' } }) + expect(wrapper.find('.inline-icon').exists()).toBe(true) + expect(wrapper.find('.app-action-icon__img').exists()).toBe(false) + }) +}) diff --git a/core/src/tests/components/AppItem.spec.ts b/core/src/tests/components/AppItem.spec.ts deleted file mode 100644 index 4212a11c5f583..0000000000000 --- a/core/src/tests/components/AppItem.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { INavigationEntry } from '../../types/navigation.d.ts' - -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' - -// Mock l10n for deterministic output; mirror real n() plural behavior. -vi.mock('@nextcloud/l10n', () => ({ - t: (_app: string, text: string) => text, - n: (_app: string, singular: string, plural: string, count: number, vars?: Record) => { - const template = count === 1 ? singular : plural - return template.replace(/\{count\}/g, String(vars?.count ?? count)) - }, -})) - -import AppItem from '../../components/AppItem.vue' - -function makeApp(overrides: Partial = {}): INavigationEntry { - return { - id: 'files', - active: false, - order: 0, - href: '/apps/files', - icon: '/apps/files/img/app.svg', - type: 'link', - name: 'Files', - unread: 0, - ...overrides, - } -} - -describe('core: AppItem', () => { - it('renders the label', () => { - const wrapper = mount(AppItem, { propsData: { app: makeApp({ name: 'Files' }) } }) - expect(wrapper.text()).toContain('Files') - }) - - it('active app has aria-current="page"', () => { - const wrapper = mount(AppItem, { propsData: { app: makeApp({ active: true }) } }) - expect(wrapper.attributes('aria-current')).toBe('page') - }) -}) diff --git a/core/src/tests/components/AppMenu.spec.ts b/core/src/tests/components/AppMenu.spec.ts index 014e9e84e7e47..82267e3b0178a 100644 --- a/core/src/tests/components/AppMenu.spec.ts +++ b/core/src/tests/components/AppMenu.spec.ts @@ -81,6 +81,36 @@ function mockActiveSettingsEntry(overrides: Partial): void { }) } +// Navigation actions (INavigationManager::TYPE_ACTION). Without an `href` the +// entry is handler-only and activation is broadcast on the event bus. +function makeAction(id: string, href: string = ''): INavigationEntry { + return makeApp({ + id, + name: id.charAt(0).toUpperCase() + id.slice(1), + type: 'action', + href, + icon: `/core/img/actions/${id}.svg`, + }) +} + +function fakeActions(count: number): INavigationEntry[] { + const ids = ['logout', 'help', 'settings', 'status', 'about', 'shortcuts'] + return ids.slice(0, count).map((id) => makeAction(id)) +} + +// loadState implementation serving both apps and navigation actions. +function stateWith(apps: INavigationEntry[], actions: INavigationEntry[]) { + return (_app: string, key: string, fallback: unknown) => { + if (key === 'apps') { + return apps + } + if (key === 'navigationActions') { + return actions + } + return fallback + } +} + function eightApps(activeIndex: number = -1): INavigationEntry[] { const ids = ['files', 'mail', 'calendar', 'contacts', 'notes', 'photos', 'talk', 'deck'] return ids.map((id, i) => makeApp({ @@ -136,7 +166,7 @@ describe('core: AppMenu', () => { const wrapper = mount(AppMenu, { attachTo: document.body }) await openPopover(wrapper) - const items = document.querySelectorAll('[role="menuitem"]') + const items = document.querySelectorAll('.app-menu__grid [role="menuitem"]') expect(items).toHaveLength(4) const labels = Array.from(items).map((el) => el.querySelector('.app-item__label')?.textContent?.trim() ?? '') expect(labels).toEqual(['Files', 'Mail', 'Calendar', 'App store']) @@ -147,7 +177,7 @@ describe('core: AppMenu', () => { const wrapper = mount(AppMenu, { attachTo: document.body }) await openPopover(wrapper) - const items = document.querySelectorAll('[role="menuitem"]') + const items = document.querySelectorAll('.app-menu__grid [role="menuitem"]') expect(items).toHaveLength(4) const moreApps = Array.from(items).find((el) => el.textContent?.includes('More apps')) expect(moreApps).toBeTruthy() @@ -275,3 +305,232 @@ describe('core: AppMenu', () => { expect(wrapper.find('.app-menu__current-app').exists()).toBe(false) }) }) + +// The submenu of the overflowing actions is teleported next to the app menu +// popover, so it is queried from the document rather than from the row. +function rowItems(): NodeListOf { + return document.querySelectorAll('.app-menu-actions [role="menuitem"]') +} + +function submenuItems(): NodeListOf { + return document.querySelectorAll('.app-menu-actions__submenu [role="menuitem"]') +} + +function pressKey(target: Element, key: string) { + target.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + })) +} + +describe('core: AppMenu navigation actions', () => { + it('does not render the actions row when no actions are registered', async () => { + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + expect(document.querySelector('.app-menu-actions')).toBeNull() + }) + + it('renders one item per navigation action below the app grid', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(3))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + const labels = Array.from(rowItems()).map((el) => el.querySelector('.app-item__label')?.textContent?.trim()) + expect(labels).toEqual(['Logout', 'Help', 'Settings']) + // The row is a sibling of the scrolling grid, so it stays visible. + expect(document.querySelector('.app-menu__popover > :last-child')).toBe(document.querySelector('.app-menu-actions')) + }) + + it('renders actions with the flat action icon and apps with the app icon', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(3))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + expect(rowItems()[0].querySelector('.app-action-icon')).toBeTruthy() + expect(rowItems()[0].querySelector('.app-icon')).toBeNull() + expect(document.querySelector('.app-menu__grid .app-icon')).toBeTruthy() + }) + + it('shows an indicator in the color of the action, and none without a color', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), [ + { ...makeAction('upload'), color: '#ff00ff' }, + makeAction('logout'), + ])) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + const indicator = rowItems()[0].querySelector('.app-action-icon__indicator') as HTMLElement | null + expect(indicator).toBeTruthy() + expect(indicator!.style.getPropertyValue('--app-action-icon-indicator-color')).toBe('#ff00ff') + expect(rowItems()[1].querySelector('.app-action-icon__indicator')).toBeNull() + }) + + it('renders an action with a link as an anchor and one without as a button', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), [ + makeAction('help', '/settings/help'), + makeAction('logout'), + ])) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + const items = rowItems() + expect(items[0].tagName).toBe('A') + expect(items[0].getAttribute('href')).toBe('/settings/help') + expect(items[1].tagName).toBe('BUTTON') + expect(items[1].hasAttribute('href')).toBe(false) + }) + + it('broadcasts the action id on the event bus and closes the menu for actions without a link', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(2))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + rowItems()[0].click() + await wrapper.vm.$nextTick() + + expect(eventBus.emit).toHaveBeenCalledWith('core:navigation-action:clicked', 'logout') + expect(wrapper.vm.opened).toBe(false) + }) + + it('does not broadcast an event for actions that have a link', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), [makeAction('help', '/settings/help')])) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + // jsdom cannot navigate, so swallow the anchor's default action. + const swallowNavigation = (event: Event) => event.preventDefault() + document.addEventListener('click', swallowNavigation) + rowItems()[0].click() + document.removeEventListener('click', swallowNavigation) + await wrapper.vm.$nextTick() + + expect(eventBus.emit).not.toHaveBeenCalledWith('core:navigation-action:clicked', expect.anything()) + expect(wrapper.vm.opened).toBe(false) + }) + + it('arrow keys move the roving stop within the actions row', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(3))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + pressKey(document.querySelector('.app-menu-actions')!, 'ArrowRight') + await wrapper.vm.$nextTick() + + expect(document.activeElement).toBe(rowItems()[1]) + expect(rowItems()[1].getAttribute('tabindex')).toBe('0') + expect(rowItems()[0].getAttribute('tabindex')).toBe('-1') + + // Clamps at the start of the row instead of wrapping around. + pressKey(rowItems()[1], 'ArrowLeft') + pressKey(rowItems()[0], 'ArrowLeft') + await wrapper.vm.$nextTick() + + expect(document.activeElement).toBe(rowItems()[0]) + }) + + it('moves the actions that do not fit into a "More actions" submenu', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(6))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + const items = rowItems() + // Three actions plus the trailing overflow item make up the single row. + expect(items).toHaveLength(4) + expect(items[3].textContent).toContain('More actions') + expect(items[3].getAttribute('aria-haspopup')).toBe('menu') + expect(items[3].getAttribute('aria-expanded')).toBe('false') + expect(document.querySelector('.app-menu-actions__submenu')).toBeNull() + + items[3].click() + await vi.waitFor(() => { + expect(submenuItems().length).toBeGreaterThan(0) + }) + + const labels = Array.from(submenuItems()).map((el) => el.querySelector('.app-item__label')?.textContent?.trim()) + expect(labels).toEqual(['Status', 'About', 'Shortcuts']) + expect(rowItems()[3].getAttribute('aria-expanded')).toBe('true') + // The app menu itself stays open while the submenu is shown. + expect(wrapper.vm.opened).toBe(true) + }) + + it('focuses the first submenu entry on open and navigates it with arrow keys', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(6))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + rowItems()[3].click() + await vi.waitFor(() => { + expect(document.activeElement).toBe(submenuItems()[0]) + }) + + pressKey(submenuItems()[0], 'ArrowDown') + await wrapper.vm.$nextTick() + expect(document.activeElement).toBe(submenuItems()[1]) + expect(submenuItems()[1].getAttribute('tabindex')).toBe('0') + + // Clamps at the end of the list. + pressKey(submenuItems()[1], 'End') + await wrapper.vm.$nextTick() + expect(document.activeElement).toBe(submenuItems()[2]) + pressKey(submenuItems()[2], 'ArrowDown') + await wrapper.vm.$nextTick() + expect(document.activeElement).toBe(submenuItems()[2]) + }) + + it('activating a submenu action closes both the submenu and the app menu', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(6))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + rowItems()[3].click() + await vi.waitFor(() => { + expect(submenuItems().length).toBeGreaterThan(0) + }) + + // Enter has to run the same path as a click (the event-bus broadcast). + pressKey(submenuItems()[0], 'Enter') + await wrapper.vm.$nextTick() + + expect(eventBus.emit).toHaveBeenCalledWith('core:navigation-action:clicked', 'status') + expect(wrapper.vm.opened).toBe(false) + // The popover keeps its content mounted once shown, so the collapsed + // state is asserted on the trigger rather than on the submenu node. + expect(rowItems()[3].getAttribute('aria-expanded')).toBe('false') + }) + + it('Escape closes only the submenu, not the app menu', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(6))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + rowItems()[3].click() + await vi.waitFor(() => { + expect(submenuItems().length).toBeGreaterThan(0) + }) + + pressKey(submenuItems()[0], 'Escape') + await wrapper.vm.$nextTick() + + expect(rowItems()[3].getAttribute('aria-expanded')).toBe('false') + expect(wrapper.vm.opened).toBe(true) + }) + + it('Enter on the overflow item opens the submenu instead of closing the menu', async () => { + initialState.loadState.mockImplementation(stateWith(fakeApps(), fakeActions(6))) + const wrapper = mount(AppMenu, { attachTo: document.body }) + await openPopover(wrapper) + + // Roving stop 3 is the overflow item. + pressKey(document.querySelector('.app-menu-actions')!, 'End') + await wrapper.vm.$nextTick() + expect(document.activeElement).toBe(rowItems()[3]) + + pressKey(rowItems()[3], 'Enter') + await vi.waitFor(() => { + expect(submenuItems().length).toBeGreaterThan(0) + }) + expect(wrapper.vm.opened).toBe(true) + }) +}) diff --git a/core/src/tests/components/AppMenuItem.spec.ts b/core/src/tests/components/AppMenuItem.spec.ts new file mode 100644 index 0000000000000..1171b9638d84c --- /dev/null +++ b/core/src/tests/components/AppMenuItem.spec.ts @@ -0,0 +1,77 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INavigationEntry } from '../../types/navigation.d.ts' + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +// Mock l10n for deterministic output; mirror real n() plural behavior. +vi.mock('@nextcloud/l10n', () => ({ + t: (_app: string, text: string) => text, + n: (_app: string, singular: string, plural: string, count: number, vars?: Record) => { + const template = count === 1 ? singular : plural + return template.replace(/\{count\}/g, String(vars?.count ?? count)) + }, +})) + +import AppMenuItem from '../../components/AppMenuItem.vue' + +function makeApp(overrides: Partial = {}): INavigationEntry { + return { + id: 'files', + active: false, + order: 0, + href: '/apps/files', + icon: '/apps/files/img/app.svg', + type: 'link', + name: 'Files', + unread: 0, + ...overrides, + } +} + +describe('core: AppMenuItem', () => { + it('renders the label', () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ name: 'Files' }) } }) + expect(wrapper.text()).toContain('Files') + }) + + it('active app has aria-current="page"', () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ active: true }) } }) + expect(wrapper.attributes('aria-current')).toBe('page') + }) + + it('renders an anchor for entries with a target', () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ href: '/apps/files' }) } }) + expect(wrapper.element.tagName).toBe('A') + expect(wrapper.attributes('href')).toBe('/apps/files') + }) + + it('renders a button for entries without a target', () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ href: '' }) } }) + expect(wrapper.element.tagName).toBe('BUTTON') + expect(wrapper.attributes('type')).toBe('button') + expect(wrapper.attributes('href')).toBeUndefined() + }) + + it('renders the app icon by default and lets consumers replace it', () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp() } }) + expect(wrapper.find('.app-icon').exists()).toBe(true) + + const slotted = mount(AppMenuItem, { + propsData: { app: makeApp() }, + slots: { icon: '' }, + }) + expect(slotted.find('.app-icon').exists()).toBe(false) + expect(slotted.find('.custom-icon').exists()).toBe(true) + }) + + it('emits the activation to the parent', async () => { + const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ href: '' }) } }) + await wrapper.trigger('click') + expect(wrapper.emitted('click')).toHaveLength(1) + }) +})