aboutsummaryrefslogtreecommitdiffstats
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/src/components/AppMenu.vue275
-rw-r--r--core/src/components/AppMenuEntry.vue128
-rw-r--r--core/src/components/AppMenuIcon.vue63
-rw-r--r--core/src/types/navigation.d.ts30
4 files changed, 291 insertions, 205 deletions
diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue
index e84a1250222..33e1a194f3c 100644
--- a/core/src/components/AppMenu.vue
+++ b/core/src/components/AppMenu.vue
@@ -6,99 +6,99 @@
<template>
<nav class="app-menu"
:aria-label="t('core', 'Applications menu')">
- <ul class="app-menu-main">
- <li v-for="app in mainAppList"
+ <ul class="app-menu__list">
+ <AppMenuEntry v-for="app in mainAppList"
:key="app.id"
- :data-app-id="app.id"
- class="app-menu-entry"
- :class="{ 'app-menu-entry__active': app.active }">
- <a :href="app.href"
- :class="{ 'has-unread': app.unread > 0 }"
- :aria-label="appLabel(app)"
- :title="app.name"
- :aria-current="app.active ? 'page' : false"
- :target="app.target ? '_blank' : undefined"
- :rel="app.target ? 'noopener noreferrer' : undefined">
- <img :src="app.icon" alt="">
- <div class="app-menu-entry--label">
- {{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
- </div>
- </a>
- </li>
+ :app="app" />
</ul>
- <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')">
+ <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')">
<NcActionLink v-for="app in popoverAppList"
:key="app.id"
- :aria-label="appLabel(app)"
:aria-current="app.active ? 'page' : false"
:href="app.href"
- class="app-menu-popover-entry">
- <template #icon>
- <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }">
- <img :src="app.icon" alt="">
- </div>
- </template>
- {{ app.name }}
- <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
- </NcActionLink>
+ :icon="app.icon"
+ :name="app.name"
+ class="app-menu__overflow-entry" />
</NcActions>
</nav>
</template>
-<script>
-import { loadState } from '@nextcloud/initial-state'
+<script lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { n, t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import AppMenuEntry from './AppMenuEntry.vue'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
+import logger from '../logger'
-export default {
+export default defineComponent({
name: 'AppMenu',
+
components: {
- NcActions, NcActionLink,
+ AppMenuEntry,
+ NcActions,
+ NcActionLink,
+ },
+
+ setup() {
+ return {
+ t,
+ n,
+ }
},
+
data() {
+ const appList = loadState<INavigationEntry[]>('core', 'apps', [])
+
return {
- apps: loadState('core', 'apps', {}),
+ appList,
appLimit: 0,
- observer: null,
+ observer: null as ResizeObserver | null,
}
},
+
computed: {
- appList() {
- return Object.values(this.apps)
- },
mainAppList() {
return this.appList.slice(0, this.appLimit)
},
popoverAppList() {
return this.appList.slice(this.appLimit)
},
- appLabel() {
- return (app) => app.name
- + (app.active ? ' (' + t('core', 'Currently open') + ')' : '')
- + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '')
- },
},
+
mounted() {
this.observer = new ResizeObserver(this.resize)
this.observer.observe(this.$el)
this.resize()
subscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
beforeDestroy() {
- this.observer.disconnect()
+ this.observer!.disconnect()
unsubscribe('nextcloud:app-menu.refresh', this.setApps)
},
+
methods: {
- setNavigationCounter(id, counter) {
- this.$set(this.apps[id], 'unread', counter)
+ setNavigationCounter(id: string, counter: number) {
+ const app = this.appList.find(({ app }) => app === id)
+ if (app) {
+ this.$set(app, 'unread', counter)
+ } else {
+ logger.warn(`Could not find app "${id}" for setting navigation count`)
+ }
},
- setApps({ apps }) {
- this.apps = apps
+
+ setApps({ apps }: { apps: INavigationEntry[]}) {
+ this.appList = apps
},
+
resize() {
- const availableWidth = this.$el.offsetWidth
+ const availableWidth = (this.$el as HTMLElement).offsetWidth
let appCount = Math.floor(availableWidth / 50) - 1
const popoverAppCount = this.appList.length - appCount
if (popoverAppCount === 1) {
@@ -110,183 +110,48 @@ export default {
this.appLimit = appCount
},
},
-}
+})
</script>
-<style lang="scss" scoped>
-$header-icon-size: 20px;
-
+<style scoped lang="scss">
.app-menu {
width: 100%;
display: flex;
flex-shrink: 1;
flex-wrap: wrap;
-}
-.app-menu-main {
- display: flex;
- flex-wrap: nowrap;
- .app-menu-entry {
- width: 50px;
- height: 50px;
- position: relative;
+ &__list {
display: flex;
+ flex-wrap: nowrap;
+ }
- &.app-menu-entry__active {
- opacity: 1;
-
- &::before {
- content: " ";
- position: absolute;
- pointer-events: none;
- border-bottom-color: var(--color-main-background);
- transform: translateX(-50%);
- width: 12px;
- height: 5px;
- border-radius: 3px;
- background-color: var(--color-background-plain-text);
- left: 50%;
- bottom: 6px;
- display: block;
- transition: all 0.1s ease-in-out;
- opacity: 1;
- }
-
- .app-menu-entry--label {
- font-weight: bold;
- }
- }
-
- a {
- width: calc(100% - 4px);
- height: calc(100% - 4px);
- margin: 2px;
- // this is shown directly on the background
- color: var(--color-background-plain-text);
- position: relative;
- }
-
- img {
- transition: margin 0.1s ease-in-out;
- width: $header-icon-size;
- height: $header-icon-size;
- padding: calc((100% - $header-icon-size) / 2);
- box-sizing: content-box;
- filter: var(--background-image-invert-if-bright);
- }
+ // Adjust the overflow NcActions styles as they are directly rendered on the background
+ &__overflow :deep(.button-vue--vue-tertiary) {
+ opacity: .7;
+ margin: 3px;
+ filter: var(--background-image-invert-if-bright);
- .app-menu-entry--label {
- opacity: 0;
- position: absolute;
- font-size: 12px;
- // this is shown directly on the background
+ /* Remove all background and align text color if not expanded */
+ &:not([aria-expanded="true"]) {
color: var(--color-background-plain-text);
- text-align: center;
- left: 50%;
- top: 45%;
- display: block;
- min-width: 100%;
- transform: translateX(-50%);
- transition: all 0.1s ease-in-out;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
- letter-spacing: -0.5px;
- }
- &:hover,
- &:focus-within {
- opacity: 1;
- .app-menu-entry--label {
+ &:hover {
opacity: 1;
- font-weight: bolder;
- bottom: 0;
- width: 100%;
- text-overflow: ellipsis;
- overflow: hidden;
+ background-color: transparent !important;
}
}
- }
-
- // Show labels
- &:hover,
- &:focus-within,
- .app-menu-entry:hover,
- .app-menu-entry:focus {
- opacity: 1;
-
- img {
- margin-top: -8px;
- }
-
- .app-menu-entry--label {
- opacity: 1;
- bottom: 0;
- }
-
- &::before, .app-menu-entry::before {
- opacity: 0;
- }
- }
-}
-
-::v-deep .app-menu-more .button-vue--vue-tertiary {
- opacity: .7;
- margin: 3px;
- filter: var(--background-image-invert-if-bright);
-
- /* Remove all background and align text color if not expanded */
- &:not([aria-expanded="true"]) {
- color: var(--color-background-plain-text);
-
- &:hover {
+ &:focus-visible {
opacity: 1;
- background-color: transparent !important;
+ outline: none !important;
}
}
- &:focus-visible {
- opacity: 1;
- outline: none !important;
- }
-}
-
-.app-menu-popover-entry {
- .app-icon {
- position: relative;
- height: 44px;
- width: 48px;
- display: flex;
- align-items: center;
- justify-content: center;
- /* Icons are bright so invert them if bright color theme == bright background is used */
- filter: var(--background-invert-if-bright);
-
- &.has-unread::after {
- background-color: var(--color-main-text);
- }
-
- img {
- width: $header-icon-size;
- height: $header-icon-size;
+ &__overflow-entry {
+ :deep(.action-link__icon) {
+ // Icons are bright so invert them if bright color theme == bright background is used
+ filter: var(--background-invert-if-bright) !important;
}
}
}
-
-.has-unread::after {
- content: "";
- width: 8px;
- height: 8px;
- background-color: var(--color-background-plain-text);
- border-radius: 50%;
- position: absolute;
- display: block;
- top: 10px;
- right: 10px;
-}
-
-.unread-counter {
- display: none;
-}
</style>
diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue
new file mode 100644
index 00000000000..d26e0c1dc06
--- /dev/null
+++ b/core/src/components/AppMenuEntry.vue
@@ -0,0 +1,128 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <li class="app-menu-entry"
+ :class="{
+ 'app-menu-entry--active': app.active,
+ }">
+ <a class="app-menu-entry__link"
+ :href="app.href"
+ :title="app.name"
+ :aria-current="app.active ? 'page' : false"
+ :target="app.target ? '_blank' : undefined"
+ :rel="app.target ? 'noopener noreferrer' : undefined">
+ <AppMenuIcon class="app-menu-entry__icon" :app="app" />
+ <span class="app-menu-entry__label">
+ {{ app.name }}
+ </span>
+ </a>
+ </li>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+import AppMenuIcon from './AppMenuIcon.vue'
+
+defineProps<{
+ app: INavigationEntry
+}>()
+</script>
+
+<style scoped lang="scss">
+.app-menu-entry {
+ width: var(--header-height);
+ height: var(--header-height);
+ position: relative;
+
+ &__link {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ // Set color as this is shown directly on the background
+ color: var(--color-background-plain-text);
+ // Make space for focus-visible outline
+ width: calc(100% - 4px);
+ height: calc(100% - 4px);
+ margin: 2px;
+ }
+
+ &__label {
+ opacity: 0;
+ position: absolute;
+ font-size: 12px;
+ line-height: 1.25;
+ // this is shown directly on the background
+ color: var(--color-background-plain-text);
+ text-align: center;
+ bottom: 0;
+ left: 50%;
+ display: block;
+ min-width: 100%;
+ transform: translateX(-50%);
+ transition: all 0.1s ease-in-out;
+ width: 100%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ letter-spacing: -0.5px;
+ }
+
+ &--active {
+ // When hover or focus, show the label and make it bolder than the other entries
+ .app-menu-entry__label {
+ font-weight: bolder;
+ }
+
+ // When active show a line below the entry as an "active" indicator
+ &::before {
+ content: " ";
+ position: absolute;
+ pointer-events: none;
+ border-bottom-color: var(--color-main-background);
+ transform: translateX(-50%);
+ width: 12px;
+ height: 5px;
+ border-radius: 3px;
+ background-color: var(--color-background-plain-text);
+ left: 50%;
+ bottom: 6px;
+ display: block;
+ transition: all 0.1s ease-in-out;
+ opacity: 1;
+ }
+ }
+
+ // Make the hovered entry bold to see that it is hovered
+ &:hover &__label,
+ &:focus-within &__label {
+ font-weight: bold;
+ }
+}
+</style>
+
+<style lang="scss">
+// Showing the label
+.app-menu-entry:hover .app-menu-entry,
+.app-menu-entry:focus-within .app-menu-entry,
+.app-menu__list:hover .app-menu-entry,
+.app-menu__list:focus-within .app-menu-entry {
+ // Move icon up so that the name does not overflow the icon
+ &__icon {
+ margin-block-end: calc(1.5 * 12px); // font size of label * line height
+ }
+
+ // Make the label visible
+ &__label {
+ opacity: 1;
+ }
+
+ // Hide indicator when the text is shown
+ &--active::before {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue
new file mode 100644
index 00000000000..bdf14cdd0d7
--- /dev/null
+++ b/core/src/components/AppMenuIcon.vue
@@ -0,0 +1,63 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <span class="app-menu-icon"
+ role="img"
+ :aria-hidden="ariaHidden"
+ :aria-label="ariaLabel">
+ <img class="app-menu-icon__icon" :src="app.icon">
+ <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" />
+ </span>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+import { n } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+import IconDot from 'vue-material-design-icons/Circle.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+const ariaHidden = computed(() => String(props.app.unread > 0))
+
+const ariaLabel = computed(() => {
+ if (ariaHidden.value === 'true') {
+ return ''
+ }
+ return props.app.name
+ + (props.app.unread > 0 ? ` (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` : '')
+})
+</script>
+
+<style scoped lang="scss">
+$icon-size: 20px;
+$unread-indicator-size: 10px;
+
+.app-menu-icon {
+ box-sizing: border-box;
+ position: relative;
+
+ height: $icon-size;
+ width: $icon-size;
+
+ &__icon {
+ transition: margin 0.1s ease-in-out;
+ height: $icon-size;
+ width: $icon-size;
+ filter: var(--background-image-invert-if-bright);
+ }
+
+ &__unread {
+ color: var(--color-error);
+ position: absolute;
+ inset-block-end: calc($unread-indicator-size / -2.5);
+ inset-inline-end: calc($unread-indicator-size / -2.5);
+ }
+}
+</style>
diff --git a/core/src/types/navigation.d.ts b/core/src/types/navigation.d.ts
new file mode 100644
index 00000000000..5698aab205e
--- /dev/null
+++ b/core/src/types/navigation.d.ts
@@ -0,0 +1,30 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/** See NavigationManager */
+export interface INavigationEntry {
+ /** Navigation id */
+ id: string
+ /** If this is the currently active app */
+ active: boolean
+ /** Order where this entry should be shown */
+ order: number
+ /** Target of the navigation entry */
+ href: string
+ /** The icon used for the naviation entry */
+ icon: string
+ /** Type of the navigation entry ('link' vs 'settings') */
+ type: 'link' | 'settings'
+ /** Localized name of the navigation entry */
+ name: string
+ /** Whether this is the default app */
+ default?: boolean
+ /** App that registered this navigation entry (not necessarly the same as the id) */
+ app?: string
+ /** If this app has unread notification */
+ unread: number
+ /** True when the link should be opened in a new tab */
+ target?: boolean
+}