diff options
Diffstat (limited to 'core')
-rw-r--r-- | core/src/components/AppMenu.vue | 275 | ||||
-rw-r--r-- | core/src/components/AppMenuEntry.vue | 128 | ||||
-rw-r--r-- | core/src/components/AppMenuIcon.vue | 63 | ||||
-rw-r--r-- | core/src/types/navigation.d.ts | 30 |
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 +} |