refactor(core): Split app menu into components

This allows to split one large block of code into three components with each one usecase.
Allowing for better readability and maintainablility.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2024-07-08 16:25:14 +02:00
parent a96b5940dd
commit d82565d67d
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
3 changed files with 263 additions and 207 deletions

View file

@ -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,
},
data() {
setup() {
return {
apps: loadState('core', 'apps', {}),
appLimit: 0,
observer: null,
t,
n,
}
},
data() {
const appList = loadState<INavigationEntry[]>('core', 'apps', [])
return {
appList,
appLimit: 0,
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;
// 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);
&::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
/* Remove all background and align text color if not expanded */
&:not([aria-expanded="true"]) {
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);
}
.app-menu-entry--label {
opacity: 0;
position: absolute;
font-size: 12px;
// this is shown directly on the background
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 {
&:focus-visible {
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 {
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>

View file

@ -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>

View file

@ -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>