Skip to content

Commit a43cbe6

Browse files
authored
Merge pull request #63134 from nextcloud/feat/navigation-actions
feat: add navigation actions to the waffle menu
2 parents ab7b254 + f868efd commit a43cbe6

37 files changed

Lines changed: 1279 additions & 131 deletions

core/Controller/NavigationController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
/**
2222
* @psalm-import-type CoreNavigationEntry from ResponseDefinitions
23+
* @psalm-import-type CoreNavigationSettingsEntry from ResponseDefinitions
2324
*/
2425
class NavigationController extends OCSController {
2526
public function __construct(
@@ -48,6 +49,7 @@ public function getAppsNavigation(bool $absolute = false): DataResponse {
4849
if ($absolute) {
4950
$navigation = $this->rewriteToAbsoluteUrls($navigation);
5051
}
52+
/** @var list<CoreNavigationEntry> $navigation */
5153
$navigation = array_values($navigation);
5254
$response = new DataResponse($navigation);
5355
$response->setETag($this->generateETag($navigation));
@@ -58,7 +60,7 @@ public function getAppsNavigation(bool $absolute = false): DataResponse {
5860
* Get the settings navigation
5961
*
6062
* @param bool $absolute Rewrite URLs to absolute ones
61-
* @return DataResponse<Http::STATUS_OK, list<CoreNavigationEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}>
63+
* @return DataResponse<Http::STATUS_OK, list<CoreNavigationSettingsEntry>, array{}>|DataResponse<Http::STATUS_NOT_MODIFIED, list<empty>, array{}>
6264
*
6365
* 200: Apps navigation returned
6466
* 304: No apps navigation changed
@@ -71,6 +73,7 @@ public function getSettingsNavigation(bool $absolute = false): DataResponse {
7173
if ($absolute) {
7274
$navigation = $this->rewriteToAbsoluteUrls($navigation);
7375
}
76+
/** @var list<CoreNavigationSettingsEntry> $navigation */
7477
$navigation = array_values($navigation);
7578
$response = new DataResponse($navigation);
7679
$response->setETag($this->generateETag($navigation));

core/ResponseDefinitions.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,21 @@
2929
* order?: int,
3030
* href: string,
3131
* icon: string,
32-
* type: string,
32+
* type: 'link',
33+
* name: string,
34+
* app?: string,
35+
* default?: bool,
36+
* active: bool,
37+
* classes: string,
38+
* unread: int,
39+
* }
40+
*
41+
* @psalm-type CoreNavigationSettingsEntry = array{
42+
* id: string,
43+
* order?: int,
44+
* href: string,
45+
* icon: string,
46+
* type: 'settings',
3347
* name: string,
3448
* app?: string,
3549
* default?: bool,

core/openapi-full.json

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,64 @@
263263
"type": "string"
264264
},
265265
"type": {
266+
"type": "string",
267+
"enum": [
268+
"link"
269+
]
270+
},
271+
"name": {
272+
"type": "string"
273+
},
274+
"app": {
275+
"type": "string"
276+
},
277+
"default": {
278+
"type": "boolean"
279+
},
280+
"active": {
281+
"type": "boolean"
282+
},
283+
"classes": {
284+
"type": "string"
285+
},
286+
"unread": {
287+
"type": "integer",
288+
"format": "int64"
289+
}
290+
}
291+
},
292+
"NavigationSettingsEntry": {
293+
"type": "object",
294+
"required": [
295+
"id",
296+
"href",
297+
"icon",
298+
"type",
299+
"name",
300+
"active",
301+
"classes",
302+
"unread"
303+
],
304+
"properties": {
305+
"id": {
266306
"type": "string"
267307
},
308+
"order": {
309+
"type": "integer",
310+
"format": "int64"
311+
},
312+
"href": {
313+
"type": "string"
314+
},
315+
"icon": {
316+
"type": "string"
317+
},
318+
"type": {
319+
"type": "string",
320+
"enum": [
321+
"settings"
322+
]
323+
},
268324
"name": {
269325
"type": "string"
270326
},
@@ -3506,7 +3562,7 @@
35063562
"data": {
35073563
"type": "array",
35083564
"items": {
3509-
"$ref": "#/components/schemas/NavigationEntry"
3565+
"$ref": "#/components/schemas/NavigationSettingsEntry"
35103566
}
35113567
}
35123568
}

core/openapi.json

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,64 @@
263263
"type": "string"
264264
},
265265
"type": {
266+
"type": "string",
267+
"enum": [
268+
"link"
269+
]
270+
},
271+
"name": {
272+
"type": "string"
273+
},
274+
"app": {
275+
"type": "string"
276+
},
277+
"default": {
278+
"type": "boolean"
279+
},
280+
"active": {
281+
"type": "boolean"
282+
},
283+
"classes": {
284+
"type": "string"
285+
},
286+
"unread": {
287+
"type": "integer",
288+
"format": "int64"
289+
}
290+
}
291+
},
292+
"NavigationSettingsEntry": {
293+
"type": "object",
294+
"required": [
295+
"id",
296+
"href",
297+
"icon",
298+
"type",
299+
"name",
300+
"active",
301+
"classes",
302+
"unread"
303+
],
304+
"properties": {
305+
"id": {
266306
"type": "string"
267307
},
308+
"order": {
309+
"type": "integer",
310+
"format": "int64"
311+
},
312+
"href": {
313+
"type": "string"
314+
},
315+
"icon": {
316+
"type": "string"
317+
},
318+
"type": {
319+
"type": "string",
320+
"enum": [
321+
"settings"
322+
]
323+
},
268324
"name": {
269325
"type": "string"
270326
},
@@ -3506,7 +3562,7 @@
35063562
"data": {
35073563
"type": "array",
35083564
"items": {
3509-
"$ref": "#/components/schemas/NavigationEntry"
3565+
"$ref": "#/components/schemas/NavigationSettingsEntry"
35103566
}
35113567
}
35123568
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import { computed } from 'vue'
8+
import IconPlus from 'vue-material-design-icons/Plus.vue'
9+
10+
const props = defineProps<{
11+
/** URL of the icon, painted as-is so the colors of the action are kept. */
12+
icon?: string
13+
/** Color of the indicator; without one no indicator is rendered. */
14+
color?: string
15+
}>()
16+
17+
// Escaped so a crafted path cannot break out of the url() token.
18+
const iconStyle = computed(() => ({
19+
'--app-action-icon-url': `url("${(props.icon ?? '').replace(/["\\]/g, '\\$&')}")`,
20+
}))
21+
22+
const indicatorStyle = computed(() => ({
23+
'--app-action-icon-indicator-color': props.color,
24+
}))
25+
</script>
26+
27+
<template>
28+
<span class="app-action-icon">
29+
<!-- @slot Icon to render instead of the `icon` URL, e.g. an inline icon component. -->
30+
<slot>
31+
<span
32+
v-if="icon"
33+
class="app-action-icon__img"
34+
:style="iconStyle"
35+
aria-hidden="true" />
36+
</slot>
37+
<span
38+
v-if="color"
39+
class="app-action-icon__indicator"
40+
:style="indicatorStyle"
41+
aria-hidden="true">
42+
<IconPlus />
43+
</span>
44+
</span>
45+
</template>
46+
47+
<style scoped lang="scss">
48+
.app-action-icon {
49+
// Consumers size the icon through --app-action-icon-size, see AppMenuItem.
50+
--app-action-icon-glyph-size: calc(var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12)) * 0.8);
51+
position: relative;
52+
box-sizing: border-box;
53+
display: flex;
54+
align-items: center;
55+
justify-content: center;
56+
width: var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12));
57+
height: var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12));
58+
transform: scale(var(--app-icon-scale, 1));
59+
transition: transform var(--animation-quick) ease-out;
60+
61+
@media (prefers-reduced-motion: reduce) {
62+
transition: none;
63+
}
64+
65+
&__img {
66+
width: var(--app-action-icon-glyph-size);
67+
height: var(--app-action-icon-glyph-size);
68+
background: var(--app-action-icon-url) center / contain no-repeat;
69+
}
70+
71+
// Slotted icon components ship their own SVG dimensions.
72+
:deep(.material-design-icon) {
73+
width: var(--app-action-icon-glyph-size);
74+
height: var(--app-action-icon-glyph-size);
75+
76+
svg {
77+
width: 100%;
78+
height: 100%;
79+
}
80+
}
81+
82+
&__indicator {
83+
--app-action-icon-indicator-size: max(12px, calc(var(--app-action-icon-size, calc(var(--default-grid-baseline) * 12)) * 0.36));
84+
position: absolute;
85+
inset-block-end: 0;
86+
inset-inline-end: 0;
87+
display: flex;
88+
align-items: center;
89+
justify-content: center;
90+
width: var(--app-action-icon-indicator-size);
91+
height: var(--app-action-icon-indicator-size);
92+
border-radius: 50%;
93+
background-color: var(--app-action-icon-indicator-color);
94+
// Separates the indicator from the icon underneath.
95+
border: 2px solid var(--color-main-background);
96+
box-sizing: content-box;
97+
// The indicator color is app-provided and saturated, so the glyph on top
98+
// of it cannot follow the theme text color.
99+
color: #fff;
100+
101+
:deep(svg) {
102+
width: 100%;
103+
height: 100%;
104+
}
105+
}
106+
}
107+
</style>

core/src/components/AppIcon.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@
2020
<script setup lang="ts">
2121
import { computed } from 'vue'
2222
23-
const props = withDefaults(defineProps<{
23+
const props = defineProps<{
2424
/** URL of the app icon, used as a CSS mask. */
2525
icon: string
26+
2627
/** Render the circle as an outline only (no fill or gradient). */
2728
outlined?: boolean
28-
}>(), {
29-
outlined: false,
30-
})
29+
}>()
3130
3231
// Escaped so a crafted path cannot break out of the url() token.
3332
const iconStyle = computed(() => ({
@@ -52,6 +51,7 @@ $bevel:
5251
justify-content: center;
5352
width: var(--app-icon-circle-size);
5453
height: var(--app-icon-circle-size);
54+
min-height: var(--app-icon-circle-size);
5555
border-radius: 50%;
5656
transform: scale(var(--app-icon-scale, 1));
5757
transition: transform var(--animation-quick) ease-out;

0 commit comments

Comments
 (0)