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
6 changes: 4 additions & 2 deletions core/src/components/AppIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,16 @@ $bevel:
}
}

// Utility entries ("More apps", "App store") stay subdued: a plain circle
// with the icon in the same muted color as the label.
&--outlined {
background: transparent;
background-image: none;
box-shadow: inset 0 0 0 2px var(--color-border-maxcontrast);
box-shadow: inset 0 0 0 2px var(--color-border);
}

&--outlined &__img {
background-color: var(--color-main-text);
background-color: var(--color-text-maxcontrast);
background-image: none;
}
}
Expand Down
6 changes: 6 additions & 0 deletions core/src/components/AppItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
class="app-item"
:class="{
'app-item--active': app.active,
'app-item--outlined': outlined,
}"
:href="app.href"
:target="newTab ? '_blank' : undefined"
Expand Down Expand Up @@ -125,5 +126,10 @@ const unreadLabel = computed(() => {
&--active &__label {
font-weight: bold;
}

// Utility entries ("More apps", "App store") are subdued, they are not apps.
&--outlined &__label {
color: var(--color-text-maxcontrast);
}
}
</style>
73 changes: 57 additions & 16 deletions core/src/components/AppMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,19 @@
:aria-expanded="opened ? 'true' : 'false'"
@click="onTriggerClick('currentApp')">
<template #icon>
<!-- Settings sub-sections share one generic cog. An inline MDI icon
inherits the button's currentColor (--color-background-plain-text),
<!-- Sections of the settings app share one generic cog. An inline MDI
icon inherits the button's currentColor (--color-background-plain-text),
so it stays legible on both bright and dark headers without a filter. -->
<IconCog
v-if="currentApp.type === 'settings'"
v-if="isSettingsSection"
class="app-menu__current-app-cog"
:size="20" />
<img
v-else
class="app-menu__current-app-icon"
:src="currentApp.icon"
alt=""
aria-hidden="true">
<!-- Outer element carries the header fade, inner one the icon shape. -->
<span v-else class="app-menu__current-app-icon">
<span
class="app-menu__current-app-glyph"
:style="currentAppIconStyle" />
</span>
</template>
<span class="app-menu__current-app-name">
{{ displayName }}
Expand Down Expand Up @@ -94,6 +94,14 @@ import logger from '../logger.js'
// Settings IDs that represent actions, not navigable pages.
const SETTINGS_ACTION_IDS = new Set(['logout'])

// Sections of the settings app itself. Their names ("Personal settings",
// "Appearance and accessibility", ...) are too long and varied for the header,
// so they all show as "Settings". Other settings entries keep their own name.
Comment on lines +97 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI comments like this are pretty bad. They will get outdated - because no one updates such comments - and once someone / something tries to understand whats going on it will just confuse.
Basically the reason why comments explaining the implementation are an anti-pattern, instead implementation should be self explaining.

(this is just an example there are more below - also using // will make them pretty useless as they are not even doc blocks).

Comment on lines +97 to +99

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Sections of the settings app itself. Their names ("Personal settings",
// "Appearance and accessibility", ...) are too long and varied for the header,
// so they all show as "Settings". Other settings entries keep their own name.

const SETTINGS_SECTION_IDS = new Set(['settings_personal', 'settings_administration', 'accessibility_settings'])

// Entry of the app management page, the target of the "More apps" tile.
const APP_MANAGEMENT_ID = 'appstore'

export default defineComponent({
name: 'AppMenu',

Expand Down Expand Up @@ -169,18 +177,31 @@ export default defineComponent({
?? Object.values(this.settingsList).find((entry) => entry.active && !SETTINGS_ACTION_IDS.has(entry.id))
},

// Trigger label. Settings sub-section names ("Personal info",
// "Appearance and accessibility", ...) are too long and varied to
// surface in the header; collapse them all to a single "Settings".
isSettingsSection(): boolean {
return this.currentApp !== undefined && SETTINGS_SECTION_IDS.has(this.currentApp.id)
},

// Trigger label. Sections of the settings app show as "Settings",
// see SETTINGS_SECTION_IDS. All other entries use their own name,
// so the app management page shows "Apps" like in the account menu.
Comment on lines +184 to +186

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Trigger label. Sections of the settings app show as "Settings",
// see SETTINGS_SECTION_IDS. All other entries use their own name,
// so the app management page shows "Apps" like in the account menu.

displayName(): string {
if (!this.currentApp) {
return ''
}
return this.currentApp.type === 'settings'
return this.isSettingsSection
? t('core', 'Settings')
: this.currentApp.name
},

// The icon is painted through a mask, so entries with a dark icon
// (the app management page ships one for the settings list) are legible
// on the header as well. Escaped so a crafted path cannot break out of
// the url() token, same as AppIcon.vue.
currentAppIconStyle(): Record<string, string> {
const icon = this.currentApp?.icon ?? ''
return { '--app-icon-url': `url("${icon.replace(/["\\]/g, '\\$&')}")` }
},

// aria-label overrides the inner span text, so the displayed name
// has to be duplicated here for screen readers.
currentAppLabel(): string {
Expand All @@ -193,7 +214,11 @@ export default defineComponent({
// utility tile is "More apps" (local app management) for admins and
// "App store" (apps.nextcloud.com) for everyone else.
gridItems(): INavigationEntry[] {
const tail = this.isAdmin ? this.moreAppsEntry : this.appStoreEntry
// On the app management page the "More apps" tile is the current
// entry, so it is marked active like any other app tile.
Comment on lines +217 to +218

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// On the app management page the "More apps" tile is the current
// entry, so it is marked active like any other app tile.

const tail = this.isAdmin
? { ...this.moreAppsEntry, active: this.currentApp?.id === APP_MANAGEMENT_ID }
: this.appStoreEntry
return [...this.appList, tail]
},
},
Expand Down Expand Up @@ -459,13 +484,29 @@ export default defineComponent({
}

&__current-app-icon {
display: flex;
width: calc(var(--default-grid-baseline) * 5);
height: calc(var(--default-grid-baseline) * 5);
// Theme-aware inversion + vertical alpha fade via --header-menu-icon-mask.
filter: var(--background-image-invert-if-bright);
// Vertical alpha fade, like the cog and the other header icons.
mask: var(--header-menu-icon-mask);
}

&__current-app-glyph {
width: 100%;
height: 100%;
// Masked rather than shown: app icons ship a hardcoded fill, so the
// color has to come from the background. Matches AppIcon.vue.
background-color: var(--color-background-plain-text);
mask: var(--app-icon-url) center / contain no-repeat;
}

// Masked backgrounds are not force-adjusted the way <img> is.
@media (forced-colors: active) {
&__current-app-glyph {
background-color: CanvasText;
}
}

&__current-app-cog {
mask: var(--header-menu-icon-mask);
}
Expand Down
45 changes: 42 additions & 3 deletions core/src/tests/components/AppMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ describe('core: AppMenu', () => {
expect(moreApps).toBeTruthy()
})

it('marks the "More apps" tile active on the app management page', async () => {
auth.getCurrentUser.mockReturnValue({ isAdmin: true })
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
if (key === 'apps') {
return [makeApp({ id: 'files', name: 'Files', active: false })]
}
if (key === 'settingsNavEntries') {
return { appstore: makeApp({ id: 'appstore', name: 'Apps', type: 'settings', href: '/settings/apps', active: true }) }
}
return fallback
})
const wrapper = mount(AppMenu, { attachTo: document.body })
await openPopover(wrapper)

const moreApps = Array.from(document.querySelectorAll('[role="menuitem"]'))
.find((el) => el.textContent?.includes('More apps'))
expect(moreApps?.classList.contains('app-item--active')).toBe(true)
expect(moreApps?.getAttribute('aria-current')).toBe('page')
})

it('ArrowRight moves the roving stop from index 0 to index 1 and focuses it', async () => {
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => key === 'apps' ? eightApps() : fallback)
const wrapper = mount(AppMenu, { attachTo: document.body })
Expand Down Expand Up @@ -184,8 +204,8 @@ describe('core: AppMenu', () => {
// Object keyed by entry id — matches PHP's serialization shape
// (TemplateLayout ships the filtered associative array as-is).
return {
admin_settings: makeApp({
id: 'admin_settings',
settings_administration: makeApp({
id: 'settings_administration',
name: 'Administration settings',
type: 'settings',
href: '/settings/admin/overview',
Expand All @@ -202,13 +222,32 @@ describe('core: AppMenu', () => {
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Settings')
})

it('keeps the own name of settings entries outside the settings app', () => {
// On /settings/apps the active entry is the app management one, which
// shows "Apps" here and in the account menu, not "Settings".
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
if (key === 'apps') {
return [makeApp({ id: 'files', name: 'Files', active: false })]
}
if (key === 'settingsNavEntries') {
return { appstore: makeApp({ id: 'appstore', name: 'Apps', type: 'settings', href: '/settings/apps', icon: '/apps/appstore/img/app-dark.svg', active: true }) }
}
return fallback
})
const wrapper = mount(AppMenu, { attachTo: document.body })
expect(wrapper.find('.app-menu__current-app-name').text()).toBe('Apps')
// Its own icon, not the generic cog of the settings sections
expect(wrapper.find('.app-menu__current-app-glyph').attributes('style'))
.toContain('/apps/appstore/img/app-dark.svg')
})

it('prefers the active app over a settings entry when both are marked active', () => {
initialState.loadState.mockImplementation((_a: string, key: string, fallback: unknown) => {
if (key === 'apps') {
return [makeApp({ id: 'files', name: 'Files', active: true })]
}
if (key === 'settingsNavEntries') {
return { admin_settings: makeApp({ id: 'admin_settings', name: 'Administration settings', type: 'settings', active: true }) }
return { settings_administration: makeApp({ id: 'settings_administration', name: 'Administration settings', type: 'settings', active: true }) }
}
return fallback
})
Expand Down
4 changes: 2 additions & 2 deletions dist/core-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-main.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/core-unified-search.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-unified-search.js.map

Large diffs are not rendered by default.