Skip to content

Commit 87d4c6b

Browse files
feat(NcAppNavigation): add n hotkey to toggle navigation
Signed-off-by: Raimund Schlüßler <[email protected]>
1 parent 2220800 commit 87d4c6b

File tree

12 files changed

+182
-11
lines changed

12 files changed

+182
-11
lines changed

l10n/messages.pot

+1-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ msgstr ""
250250
msgid "Open menu"
251251
msgstr ""
252252

253-
msgid "Open navigation"
253+
msgid "Open navigation {shortcut}"
254254
msgstr ""
255255

256256
msgid "Open sidebar"

package-lock.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"splitpanes": "^3.1.8",
9999
"string-length": "^6.0.0",
100100
"striptags": "^3.2.0",
101+
"tabbable": "^6.2.0",
101102
"tributejs": "^5.1.3",
102103
"unified": "^11.0.5",
103104
"unist-builder": "^4.0.0",

src/components/NcAppNavigation/NcAppNavigation.vue

+49-5
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,13 @@ emit('toggle-navigation', {
165165
</template>
166166

167167
<script>
168-
import { useIsMobile } from '../../composables/useIsMobile/index.js'
169-
import { getTrapStack } from '../../utils/focusTrap.js'
170-
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
171168
import { createFocusTrap } from 'focus-trap'
169+
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
170+
import { tabbable } from 'tabbable'
171+
172+
import { getTrapStack } from '../../utils/focusTrap.js'
173+
import { useHotKey } from '../../composables/useHotKey/index.js'
174+
import { useIsMobile } from '../../composables/useIsMobile/index.js'
172175

173176
import NcAppNavigationToggle from '../NcAppNavigationToggle/index.js'
174177
import NcAppNavigationList from '../NcAppNavigationList/index.js'
@@ -246,6 +249,12 @@ export default {
246249
escapeDeactivates: false,
247250
})
248251
this.toggleFocusTrap()
252+
253+
// N opens + focuses the navigation
254+
useHotKey('n', this.onKeyDown, {
255+
prevent: true,
256+
stop: true,
257+
})
249258
},
250259
unmounted() {
251260
this.setHasAppNavigation(false)
@@ -259,7 +268,7 @@ export default {
259268
*
260269
* @param {boolean} [state] set the state instead of inverting the current one
261270
*/
262-
toggleNavigation(state) {
271+
async toggleNavigation(state) {
263272
// Early return if already in that state
264273
if (this.open === state) {
265274
emit('navigation-toggled', {
@@ -272,6 +281,12 @@ export default {
272281
const bodyStyles = getComputedStyle(document.body)
273282
const animationLength = parseInt(bodyStyles.getPropertyValue('--animation-quick')) || 100
274283

284+
// If we just opened, we focus the first element
285+
if (this.open) {
286+
await this.$nextTick()
287+
this.focusFirstElement()
288+
}
289+
275290
setTimeout(() => {
276291
emit('navigation-toggled', {
277292
open: this.open,
@@ -296,10 +311,39 @@ export default {
296311
},
297312

298313
handleEsc() {
299-
if (this.isMobile) {
314+
if (this.isMobile && this.open) {
300315
this.toggleNavigation(false)
301316
}
302317
},
318+
319+
focusFirstElement() {
320+
const element = tabbable(this.$refs.appNavigationContainer)[0]
321+
if (element) {
322+
element.focus()
323+
logger.debug('Focusing first element in the navigation', { element })
324+
}
325+
},
326+
327+
onKeyDown(event) {
328+
// toggle the navigation on 'n' key
329+
if (event.key === 'n') {
330+
// If the navigation is closed, open it
331+
if (!this.open) {
332+
this.toggleNavigation(true)
333+
return
334+
}
335+
336+
// If the navigation is open and the focus is within the navigation, close it
337+
if (this.isFocusWithinNavigation()) {
338+
this.toggleNavigation(false)
339+
}
340+
}
341+
},
342+
343+
isFocusWithinNavigation() {
344+
const activeElement = document.activeElement
345+
return this.$refs.appNavigationContainer.contains(activeElement)
346+
},
303347
},
304348
}
305349
</script>

src/components/NcAppNavigationToggle/NcAppNavigationToggle.vue

+10-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
:aria-label="label"
1212
:title="label"
1313
aria-controls="app-navigation-vue"
14+
:aria-keyshortcuts="disableKeyboardShortcuts ? '' : 'n'"
1415
@click="toggleNavigation">
1516
<template #icon>
1617
<MenuOpenIcon v-if="open" :size="20" />
@@ -27,6 +28,8 @@ import { t } from '../../l10n.js'
2728
import MenuIcon from 'vue-material-design-icons/Menu.vue'
2829
import MenuOpenIcon from 'vue-material-design-icons/MenuOpen.vue'
2930

31+
const disableKeyboardShortcuts = window.OCP?.Accessibility?.disableKeyboardShortcuts?.()
32+
3033
export default {
3134
name: 'NcAppNavigationToggle',
3235

@@ -50,9 +53,15 @@ export default {
5053

5154
emits: ['update:open'],
5255

56+
setup() {
57+
return { disableKeyboardShortcuts }
58+
},
59+
5360
computed: {
5461
label() {
55-
return this.open ? t('Close navigation') : t('Open navigation')
62+
return this.open
63+
? t('Close navigation')
64+
: t('Open navigation {shortcut}', { shortcut: disableKeyboardShortcuts ? '' : '[n]' }).trim()
5665
},
5766
},
5867
methods: {

src/components/NcPasswordField/NcPasswordField.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ import axios from '@nextcloud/axios'
125125
import { loadState } from '@nextcloud/initial-state'
126126
import { generateOcsUrl } from '@nextcloud/router'
127127
import { t } from '../../l10n.js'
128-
import logger from '../../utils/logger.js'
128+
import { logger } from '../../utils/logger.ts'
129129

130130
/**
131131
* @typedef PasswordPolicy

src/utils/logger.js renamed to src/utils/logger.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { getLoggerBuilder } from '@nextcloud/logger'
77

8-
export default getLoggerBuilder()
8+
export const logger = getLoggerBuilder()
99
.detectUser()
1010
.setApp('@nextcloud/vue')
1111
.build()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import type { HooksConfig } from '../../setup/index'
6+
import { expect, test } from '@playwright/experimental-ct-vue'
7+
8+
import AppNavigation from './NcAppNavigation.story.vue'
9+
10+
test.skip(({ browserName }) => browserName !== 'chromium')
11+
12+
// A little bit hacky but we test a wrapper element so we need to use the real NcContent and NcAppNavigation
13+
test.beforeEach(async ({ mount, page }) => {
14+
const handle = await page.locator('#app-content').elementHandle()
15+
expect(handle).not.toBeNull()
16+
await handle!.evaluate((node) => { node.innerHTML = ''; node.id = 'root' })
17+
18+
await mount<HooksConfig>(AppNavigation, {
19+
hooksConfig: {
20+
routes: [
21+
{ path: '/', component: AppNavigation },
22+
{ path: '/foo', component: AppNavigation },
23+
],
24+
},
25+
})
26+
})
27+
28+
test('opens on n keyboard press', async ({ page }) => {
29+
await expect(page.getByText('First')).toBeVisible();
30+
31+
// cy.get('nav').then(($nav) => {
32+
// const id = $nav.attr('id')
33+
// cy.get(`[aria-controls="${id}"`).as('appNavigationToggle')
34+
// cy.get('@appNavigationToggle').should('have.attr', 'aria-expanded', 'true')
35+
// cy.get('nav').should('have.attr', 'aria-hidden', 'false')
36+
// cy.get('nav').should('not.have.attr', 'inert')
37+
38+
// // close the sidebar
39+
// cy.get('@appNavigationToggle').click()
40+
41+
// cy.get('@appNavigationToggle').should('have.attr', 'aria-expanded', 'false')
42+
// cy.get('nav').should('have.attr', 'aria-hidden', 'true')
43+
// cy.get('nav').should('have.attr', 'inert')
44+
45+
// // open the sidebar with the keyboard
46+
// cy.get('body').type('n')
47+
48+
// cy.get('@appNavigationToggle').should('have.attr', 'aria-expanded', 'true')
49+
// cy.get('nav').should('have.attr', 'aria-hidden', 'false')
50+
// cy.get('nav').should('not.have.attr', 'inert')
51+
52+
// // make sure we auto-focus the first item
53+
// cy.document().then((doc) => {
54+
// const activeElement = doc.activeElement
55+
// const navigation = doc.querySelector('nav')
56+
// // eslint-disable-next-line no-unused-expressions
57+
// expect(navigation?.contains(activeElement)).to.be.true
58+
// })
59+
// })
60+
})
61+
62+
test('closes on n keyboard press', async ({ page }) => {
63+
await expect(page.getByText('First')).toBeVisible()
64+
const navigation = page.getByRole('navigation')
65+
66+
await expect(navigation).toHaveAttribute('aria-hidden', 'false')
67+
await expect(navigation).not.toHaveAttribute('inert')
68+
69+
// pressing n does nothing until we focus something within
70+
page.locator('body').press('n')
71+
await expect(navigation).toHaveAttribute('aria-hidden', 'false')
72+
await expect(navigation).not.toHaveAttribute('inert')
73+
74+
// focus something within
75+
navigation.getByRole('link').first().focus()
76+
77+
// pressing n closes the sidebar
78+
page.locator('body').press('n')
79+
await expect(navigation).toHaveAttribute('aria-hidden', 'true')
80+
await expect(navigation).toHaveAttribute('inert')
81+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<NcContent app-name="testing">
8+
<NcAppNavigation aria-label="In-app navigation">
9+
<template #list>
10+
<NcAppNavigationItem name="First" />
11+
</template>
12+
</NcAppNavigation>
13+
<NcAppContent />
14+
</NcContent>
15+
</template>
16+
17+
<script setup lang="ts">
18+
import NcAppContent from '../../../../src/components/NcAppContent/NcAppContent.vue'
19+
import NcAppNavigation from '../../../../src/components/NcAppNavigation/NcAppNavigation.vue'
20+
import NcAppNavigationItem from '../../../../src/components/NcAppNavigationItem/NcAppNavigationItem.vue'
21+
import NcContent from '../../../../src/components/NcContent/NcContent.vue'
22+
</script>

tests/component/components/NcAppNavigationItem/visual.spec.ts renamed to tests/component/components/NcAppNavigationItem/NcAppNavigationItem.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import type { HooksConfig } from '../../setup/index'
66
import { expect, test } from '@playwright/experimental-ct-vue'
77

8-
import AppNavigation from './AppNavigation.story.vue'
8+
import AppNavigation from './NcAppNavigationItem.story.vue'
99

1010
test.skip(({ browserName }) => browserName !== 'chromium')
1111

tests/unit/components/NcAppNavigation/NcAppNavigation.spec.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,20 @@ describe('NcAppNavigation.vue', () => {
157157
expect(navigation.attributes('aria-hidden')).toBe('true')
158158
expect(navigation.attributes('inert')).toBeTruthy()
159159
expect(togglebutton.attributes('aria-expanded')).toBe('false')
160-
expect(togglebutton.attributes('aria-label')).toBe('Open navigation')
160+
expect(togglebutton.attributes('aria-label')).toBe('Open navigation [n]')
161+
})
162+
163+
it('has correct aria attributes and inert on closed navigation with disabled shortcuts', async () => {
164+
window.OCP = { Accessibility: { disableKeyboardShortcuts: () => true } }
165+
const wrapper = mount(NcAppNavigation)
166+
const togglebutton = findToggleButton(wrapper)
167+
168+
// Close navigation
169+
await togglebutton.trigger('click')
170+
expect(togglebutton.attributes('aria-label')).toBe('Open navigation [n]')
171+
172+
// Clean up
173+
delete window.OCP
161174
})
162175

163176
it('has aria-label from corresponding prop on navigation', () => {

0 commit comments

Comments
 (0)