Skip to content

Commit 7974b1b

Browse files
Merge pull request #5219 from nextcloud-libraries/fix/nc-app-sidebar--auto-return-focus
feat(NcAppSidebar): move focus to sidebar on open and auto return focus on close
2 parents 06fb526 + 6a9e2fa commit 7974b1b

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

src/components/NcActions/NcActions.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1249,11 +1249,16 @@ export default {
12491249
*/
12501250
this.$emit('open')
12511251
},
1252-
closeMenu(returnFocus = true) {
1252+
async closeMenu(returnFocus = true) {
12531253
if (!this.opened) {
12541254
return
12551255
}
12561256
1257+
// Wait for the next tick to keep the menu in DOM, allowing other components to find what button in what menu was used,
1258+
// for example, to implement auto set return focus.
1259+
// NcPopover will actually remove the menu from DOM also on the next tick.
1260+
await this.$nextTick()
1261+
12571262
this.opened = false
12581263
12591264
this.$refs.popover.clearFocusTrap({ returnFocus })

src/components/NcAppSidebar/NcAppSidebar.vue

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ export default {
348348
<aside id="app-sidebar-vue"
349349
ref="sidebar"
350350
class="app-sidebar"
351+
:aria-labelledby="`app-sidebar-vue-${uid}__header`"
351352
@keydown.esc.stop="isMobile && closeSidebar()">
352353
<header :class="{
353354
'app-sidebar-header--with-figure': hasFigure,
@@ -403,11 +404,13 @@ export default {
403404
<div class="app-sidebar-header__mainname-container">
404405
<!-- main name -->
405406
<h2 v-show="!nameEditable"
407+
:id="`app-sidebar-vue-${uid}__header`"
408+
ref="header"
406409
v-linkify="{text: name, linkify: linkifyName}"
407410
:aria-label="title"
408411
:title="title"
409412
class="app-sidebar-header__mainname"
410-
:tabindex="nameEditable ? 0 : undefined"
413+
:tabindex="nameEditable ? 0 : -1"
411414
@click.self="editName">
412415
{{ name }}
413416
</h2>
@@ -492,6 +495,7 @@ import Focus from '../../directives/Focus/index.js'
492495
import Linkify from '../../directives/Linkify/index.js'
493496
import Tooltip from '../../directives/Tooltip/index.js'
494497
import { useIsSmallMobile } from '../../composables/useIsMobile/index.js'
498+
import GenRandomId from '../../utils/GenRandomId.js'
495499
import { getTrapStack } from '../../utils/focusTrap.js'
496500
import { t } from '../../l10n.js'
497501
@@ -650,6 +654,7 @@ export default {
650654
651655
setup() {
652656
return {
657+
uid: GenRandomId(),
653658
isMobile: useIsSmallMobile(),
654659
}
655660
},
@@ -661,6 +666,7 @@ export default {
661666
favoriteTranslated: t('Favorite'),
662667
isStarred: this.starred,
663668
focusTrap: null,
669+
elementToReturnFocus: null,
664670
}
665671
},
666672
@@ -686,7 +692,16 @@ export default {
686692
},
687693
},
688694
695+
created() {
696+
this.preserveElementToReturnFocus()
697+
},
698+
689699
mounted() {
700+
// Focus sidebar on open only if it was opened by a user interaction
701+
if (this.elementToReturnFocus) {
702+
this.focus()
703+
}
704+
690705
this.toggleFocusTrap()
691706
},
692707
@@ -697,6 +712,23 @@ export default {
697712
},
698713
699714
methods: {
715+
preserveElementToReturnFocus() {
716+
// Save the element that had focus before the sidebar was opened to return back on close
717+
if (document.activeElement && document.activeElement !== document.body) {
718+
this.elementToReturnFocus = document.activeElement
719+
720+
// Special case for menus (NcActions)
721+
// If a sidebar was opened from a menu item, we want to return focus to the menu trigger instead of the item
722+
if (this.elementToReturnFocus.getAttribute('role') === 'menuitem') {
723+
const menu = this.elementToReturnFocus.closest('[role="menu"]')
724+
if (menu) {
725+
const menuTrigger = document.querySelector(`[aria-controls="${menu.id}"]`)
726+
this.elementToReturnFocus = menuTrigger
727+
}
728+
}
729+
}
730+
},
731+
700732
initFocusTrap() {
701733
if (this.focusTrap) {
702734
return
@@ -721,7 +753,7 @@ export default {
721753
/**
722754
* Activate focus trap if it is currently needed, otherwise deactivate
723755
*/
724-
toggleFocusTrap() {
756+
toggleFocusTrap() {
725757
if (this.isMobile) {
726758
this.initFocusTrap()
727759
this.focusTrap.activate()
@@ -761,6 +793,10 @@ export default {
761793
* @type {HTMLElement}
762794
*/
763795
this.$emit('closed', element)
796+
797+
// Return focus to the element that had focus before the sidebar was opened
798+
this.elementToReturnFocus?.focus({ focusVisible: true })
799+
this.elementToReturnFocus = null
764800
},
765801
766802
/**
@@ -820,6 +856,25 @@ export default {
820856
}
821857
},
822858
859+
/**
860+
* Focus the sidebar
861+
* @public
862+
*/
863+
focus() {
864+
this.$refs.header.focus()
865+
},
866+
867+
/**
868+
* Focus the active tab
869+
* @public
870+
*/
871+
focusActiveTabContent() {
872+
// If a tab is focused then probably a new trigger element moved the focus to the sidebar
873+
this.preserveElementToReturnFocus()
874+
875+
this.$refs.tabs.focusActiveTabContent()
876+
},
877+
823878
/**
824879
* Emit name change event to parent component
825880
*

src/components/NcAppSidebarTab/NcAppSidebarTab.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
:aria-label="isTablistShown() ? undefined : name"
3131
:aria-labelledby="isTablistShown() ? `tab-button-${id}` : undefined"
3232
class="app-sidebar__tab"
33-
tabindex="0"
34-
role="tabpanel"
33+
:tabindex="isTablistShown() ? 0 : -1"
34+
:role="isTablistShown() ? 'tabpanel' : undefined"
3535
@scroll="onScroll">
3636
<h3 class="hidden-visually">
3737
{{ name }}

0 commit comments

Comments
 (0)