Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/Controller/NavigationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

/**
* @psalm-import-type CoreNavigationEntry from ResponseDefinitions
* @psalm-import-type CoreNavigationSettingsEntry from ResponseDefinitions
*/
class NavigationController extends OCSController {
public function __construct(
Expand Down Expand Up @@ -48,6 +49,7 @@ public function getAppsNavigation(bool $absolute = false): DataResponse {
if ($absolute) {
$navigation = $this->rewriteToAbsoluteUrls($navigation);
}
/** @var list<CoreNavigationEntry> $navigation */
$navigation = array_values($navigation);
$response = new DataResponse($navigation);
$response->setETag($this->generateETag($navigation));
Expand All @@ -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<Http::STATUS_OK, list<CoreNavigationEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}>
* @return DataResponse<Http::STATUS_OK, list<CoreNavigationSettingsEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}>
*
* 200: Apps navigation returned
* 304: No apps navigation changed
Expand All @@ -71,6 +73,7 @@ public function getSettingsNavigation(bool $absolute = false): DataResponse {
if ($absolute) {
$navigation = $this->rewriteToAbsoluteUrls($navigation);
}
/** @var list<CoreNavigationSettingsEntry> $navigation */
$navigation = array_values($navigation);
$response = new DataResponse($navigation);
$response->setETag($this->generateETag($navigation));
Expand Down
16 changes: 15 additions & 1 deletion core/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
107 changes: 107 additions & 0 deletions core/src/components/AppActionIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import { computed } from 'vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'

const props = defineProps<{
/** URL of the icon, painted as-is so the colors of the action are kept. */
icon?: string
/** Color of the indicator; without one no indicator is rendered. */
color?: string
}>()

// Escaped so a crafted path cannot break out of the url() token.
const iconStyle = computed(() => ({
'--app-action-icon-url': `url("${(props.icon ?? '').replace(/["\\]/g, '\\$&')}")`,
}))

const indicatorStyle = computed(() => ({
'--app-action-icon-indicator-color': props.color,
}))
</script>

<template>
<span class="app-action-icon">
<!-- @slot Icon to render instead of the `icon` URL, e.g. an inline icon component. -->
<slot>
<span
v-if="icon"
class="app-action-icon__img"
:style="iconStyle"
aria-hidden="true" />
</slot>
<span
v-if="color"
class="app-action-icon__indicator"
:style="indicatorStyle"
aria-hidden="true">
<IconPlus />
</span>
</span>
</template>

<style scoped lang="scss">
.app-action-icon {
// Consumers size the icon through --app-action-icon-size, see AppMenuItem.
--app-action-icon-glyph-size: calc(var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12)) * 0.8);
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12));
height: var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12));
transform: scale(var(--app-icon-scale, 1));
transition: transform var(--animation-quick) ease-out;

@media (prefers-reduced-motion: reduce) {
transition: none;
}

&__img {
width: var(--app-action-icon-glyph-size);
height: var(--app-action-icon-glyph-size);
background: var(--app-action-icon-url) center / contain no-repeat;
}

// Slotted icon components ship their own SVG dimensions.
:deep(.material-design-icon) {
width: var(--app-action-icon-glyph-size);
height: var(--app-action-icon-glyph-size);

svg {
width: 100%;
height: 100%;
}
}

&__indicator {
--app-action-icon-indicator-size: max(12px, calc(var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12)) * 0.36));
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
width: var(--app-action-icon-indicator-size);
height: var(--app-action-icon-indicator-size);
border-radius: 50%;
background-color: var(--app-action-icon-indicator-color);
// Separates the indicator from the icon underneath.
border: 2px solid var(--color-main-background);
box-sizing: content-box;
// The indicator color is app-provided and saturated, so the glyph on top
// of it cannot follow the theme text color.
color: #fff;

:deep(svg) {
width: 100%;
height: 100%;
}
}
}
</style>
8 changes: 4 additions & 4 deletions core/src/components/AppIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@
<script setup lang="ts">
import { computed } from 'vue'

const props = withDefaults(defineProps<{
const props = defineProps<{
/** URL of the app icon, used as a CSS mask. */
icon: string

/** Render the circle as an outline only (no fill or gradient). */
outlined?: boolean
}>(), {
outlined: false,
})
}>()

// Escaped so a crafted path cannot break out of the url() token.
const iconStyle = computed(() => ({
Expand All @@ -52,6 +51,7 @@ $bevel:
justify-content: center;
width: var(--app-icon-circle-size);
height: var(--app-icon-circle-size);
min-height: var(--app-icon-circle-size);
border-radius: 50%;
transform: scale(var(--app-icon-scale, 1));
transition: transform var(--animation-quick) ease-out;
Expand Down
30 changes: 23 additions & 7 deletions core/src/components/AppMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
role="menu"
:aria-label="t('core', 'Apps')">
<div ref="grid" class="app-menu__grid" @keydown="onGridKeydown">
<AppItem
<AppMenuItem
v-for="(item, i) in gridItems"
:key="item.id"
ref="items"
Expand All @@ -43,6 +43,10 @@
:new-tab="item.id === 'app-store'"
:tabindex="i === focusedIndex ? 0 : -1" />
</div>
<AppMenuActions
v-if="navigationActions.length > 0"
:actions="navigationActions"
@click="opened = false" />
</div>
</NcPopover>
<NcButton
Expand Down Expand Up @@ -86,7 +90,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconCog from 'vue-material-design-icons/Cog.vue'
import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
import AppItem from './AppItem.vue'
import AppMenuActions from './AppMenuActions.vue'
import AppMenuItem from './AppMenuItem.vue'
import logger from '../logger.js'

// Settings IDs that represent actions, not navigable pages.
Expand All @@ -105,7 +110,8 @@ export default defineComponent({
name: 'AppMenu',

components: {
AppItem,
AppMenuActions,
AppMenuItem,
IconCog,
IconDotsGrid,
NcButton,
Expand All @@ -123,11 +129,11 @@ export default defineComponent({

data() {
const appList = loadState<INavigationEntry[]>('core', 'apps', [])
// Record<id, entry>, not an array: PHP ships getAll('settings') without
// array_values(). Matches AccountMenu.vue's usage.
const navigationActions = loadState<INavigationEntry[]>('core', 'navigationActions', [])
const settingsList = loadState<Record<string, INavigationEntry>>('core', 'settingsNavEntries', {})
return {
appList,
navigationActions,
settingsList,
isAdmin: getCurrentUser()?.isAdmin ?? false,
// Roving tabindex: only this tile has tabindex=0; arrow keys move it.
Expand Down Expand Up @@ -529,20 +535,30 @@ export default defineComponent({
}

&__popover {
// Shared by the app grid and the actions row below it, so both use the
// same column raster.
--app-item-col-width: 69px;
--app-item-row-height: calc(15 * var(--default-grid-baseline) + 1.5 * var(--default-font-size)); // 12x for icon + 3x for padding + text
// Column flex so the actions row keeps its height and the grid owns
// the remaining space (and the scrolling).
display: flex;
flex-direction: column;
max-height: calc(100vh - var(--header-height) - var(--default-grid-baseline));
max-width: calc(100vw - var(--default-grid-baseline) * 4);
background-color: var(--color-main-background);
}

&__grid {
--app-item-col-width: 69px;
--app-item-row-height: 72px;
// border-box: the JS-set max-height (see recomputeGridMaxHeight)
// needs to include padding for the peek math to hold.
box-sizing: border-box;
padding: calc(var(--default-grid-baseline) * 2);
display: grid;
grid-template-columns: repeat(4, var(--app-item-col-width));
grid-auto-rows: minmax(var(--app-item-row-height), max-content);
// Allows the grid to shrink below its content height inside the
// column flex parent, so the scroll cap always applies.
min-height: 0;
// max-height set inline by recomputeGridMaxHeight(); CSS just owns the scroll.
overflow-y: auto;
overflow-x: hidden;
Expand Down
64 changes: 64 additions & 0 deletions core/src/components/AppMenuAction.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'

import { emit as emitOnEventBus } from '@nextcloud/event-bus'
import { computed } from 'vue'
import AppActionIcon from './AppActionIcon.vue'
import AppMenuItem from './AppMenuItem.vue'

const props = withDefaults(defineProps<{
/** The navigation action to render (INavigationManager::TYPE_ACTION entry). */
action: INavigationEntry
/** Render as a one-line list row instead of a tile (used in the overflow menu). */
compact?: boolean
/** Roving-tabindex value, see AppMenuItem. */
tabindex?: number
}>(), {
tabindex: -1,
})

const emit = defineEmits<{
/** Emitted after the action was activated, so the app menu can close. */
(event: 'click', mouseEvent: MouseEvent): void
}>()

// `href` is required by NavigationManager, so handler-only actions carry an
// empty string or "#" as placeholder.
const hasLink = computed(() => Boolean(props.action.href) && props.action.href !== '#')

// AppMenuItem picks the element by `href`, so the placeholder has to be
// stripped for handler-only actions to render as a <button>.
const entry = computed<INavigationEntry>(() => hasLink.value
? props.action
: { ...props.action, href: '' })

/**
* Actions with a link simply navigate. Actions without one are implemented in
* JavaScript by the registering app, which listens on the event bus.
*
* @param mouseEvent - The click event of the underlying item
*/
function onClick(mouseEvent: MouseEvent): void {
if (!hasLink.value) {
emitOnEventBus('core:navigation:action', props.action)
}
emit('click', mouseEvent)
}
</script>

<template>
<AppMenuItem
:app="entry"
:compact="compact"
:tabindex="tabindex"
@click="onClick">
<template #icon>
<AppActionIcon :icon="action.icon" :color="action.color" />
</template>
</AppMenuItem>
</template>
Loading
Loading