From fdc93c8c76210fb393cd469fbc2223a9f76ca12d Mon Sep 17 00:00:00 2001 From: Thomas Hutterer <thutterer@gitlab.com> Date: Tue, 26 Mar 2024 21:54:01 +0000 Subject: [PATCH] Collapse sidebar on ESC key if in overlay mode Changelog: changed --- .../components/super_sidebar.vue | 40 +++++++-- .../stylesheets/framework/super_sidebar.scss | 2 +- .../components/super_sidebar_spec.js | 86 ++++++++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 57ba00ee0a6ca..9ffb756e58f19 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -10,6 +10,7 @@ import Tracking from '~/tracking'; import eventHub from '../event_hub'; import { sidebarState, + JS_TOGGLE_EXPAND_CLASS, SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, @@ -79,8 +80,14 @@ export default { }, watch: { 'sidebarState.isCollapsed': { - handler() { + handler(collapsed) { this.setupFocusTrapListener(); + + if (this.isOverlappingAndNotPeeking() && !collapsed) { + this.$nextTick(() => { + this.firstFocusableElement().focus(); + }); + } }, }, }, @@ -112,12 +119,18 @@ export default { }); toggleSuperSidebarCollapsed(!isCollapsed(), true); }, + isOverlapping() { + return GlBreakpointInstance.windowWidth() < breakpoints.xl; + }, + isOverlappingAndNotPeeking() { + return this.isOverlapping() && !(sidebarState.isHoverPeek || sidebarState.isPeek); + }, setupFocusTrapListener() { /** * Only trap focus when sidebar displays over page content to avoid * focus moving to page content and being obscured by the sidebar */ - if (GlBreakpointInstance.windowWidth() < breakpoints.xl && !this.sidebarState.isCollapsed) { + if (this.isOverlapping() && !this.sidebarState.isCollapsed) { document.addEventListener('keydown', this.focusTrap); } else { document.removeEventListener('keydown', this.focusTrap); @@ -126,6 +139,12 @@ export default { collapseSidebar() { toggleSuperSidebarCollapsed(true, false); }, + handleEscKey() { + if (this.isOverlappingAndNotPeeking()) { + this.collapseSidebar(); + document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)?.focus(); + } + }, onPeekChange(state) { if (state === STATE_CLOSED) { this.sidebarState.isPeek = false; @@ -152,10 +171,19 @@ export default { this.sidebarState.isCollapsed = true; } }, + firstFocusableElement() { + return this.$refs.userBar.$el.querySelector('a'); + }, + lastFocusableElement() { + if (this.sidebarData.is_admin) { + return this.$refs.adminAreaLink.$el; + } + return this.$refs.helpCenter.$el.querySelector('button'); + }, focusTrap(event) { const { keyCode, shiftKey } = event; - const firstFocusableElement = this.$refs.userBar.$el.querySelector('a'); - const lastFocusableElement = this.$refs.helpCenter.$el.querySelector('button'); + const firstFocusableElement = this.firstFocusableElement(); + const lastFocusableElement = this.lastFocusableElement(); if (keyCode !== TAB_KEY_CODE) return; @@ -178,7 +206,7 @@ export default { <template> <div> - <div class="super-sidebar-overlay" @click="collapseSidebar"></div> + <div ref="overlay" class="super-sidebar-overlay" @click="collapseSidebar"></div> <gl-button v-if="sidebarData.is_logged_in" class="super-sidebar-skip-to gl-sr-only-focusable gl-fixed gl-left-0 gl-m-3" @@ -197,6 +225,7 @@ export default { :inert="sidebarState.isCollapsed" @mouseenter="isMouseover = true" @mouseleave="isMouseover = false" + @keydown.esc="handleEscKey" > <h2 id="super-sidebar-heading" class="gl-sr-only"> {{ $options.i18n.primaryNavigation }} @@ -233,6 +262,7 @@ export default { <help-center ref="helpCenter" :sidebar-data="sidebarData" /> <gl-button v-if="sidebarData.is_admin" + ref="adminAreaLink" class="gl-fixed gl-right-0 gl-mr-3 gl-mt-2" data-testid="sidebar-admin-link" :href="sidebarData.admin_url" diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 919a535ba0a0f..c8bf39260fed3 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -532,7 +532,7 @@ $command-palette-spacing: px-to-rem(14px); background-color: $t-gray-a-24; z-index: $super-sidebar-z-index - 1; - @include media-breakpoint-up(md) { + @include media-breakpoint-up(xl) { display: none; } } diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 9718cb7ad159b..525bf0af9d686 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -24,6 +24,7 @@ import { } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { trackContextAccess } from '~/super_sidebar/utils'; +import { stubComponent } from 'helpers/stub_component'; import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data'; const { lg, xl } = breakpoints; @@ -41,6 +42,9 @@ const trialStatusPopoverStubTestId = 'trial-status-popover'; const TrialStatusPopoverStub = { template: `<div data-testid="${trialStatusPopoverStubTestId}" />`, }; +const UserBarStub = { + template: `<div><a href="#">link</a></div>`, +}; const peekClass = 'super-sidebar-peek'; const hasPeekedClass = 'super-sidebar-has-peeked'; @@ -82,7 +86,9 @@ describe('SuperSidebar component', () => { stubs: { TrialStatusWidget: TrialStatusWidgetStub, TrialStatusPopover: TrialStatusPopoverStub, + UserBar: stubComponent(UserBar, UserBarStub), }, + attachTo: document.body, }); }; @@ -218,7 +224,7 @@ describe('SuperSidebar component', () => { expect(wrapper.text()).toContain('Your work'); }); - it('handles event toggle-menu-header correctly', async () => { + it('handles event toggle-menu-header correctly', async () => { createWrapper(); sidebarEventHub.$emit('toggle-menu-header', false); @@ -376,4 +382,82 @@ describe('SuperSidebar component', () => { }); }); }); + + describe('focusing first focusable element', () => { + const findFirstFocusableElement = () => findUserBar().find('a'); + let focusSpy; + + beforeEach(() => { + createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } }); + focusSpy = jest.spyOn(findFirstFocusableElement().element, 'focus'); + }); + + it('focuses the first focusable element when sidebar is overlapping and not peeking', async () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + + wrapper.vm.sidebarState.isCollapsed = false; + await nextTick(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it("doesn't focus the first focusable element when sidebar is not overlapping", async () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(xl); + + wrapper.vm.sidebarState.isCollapsed = false; + await nextTick(); + + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("doesn't focus the first focusable element when sidebar is peeking", async () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + + findHoverPeekBehavior().vm.$emit('change', STATE_OPEN); + await nextTick(); + + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("doesn't focus the first focusable element when sidebar is collapsed", async () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + + wrapper.vm.sidebarState.isCollapsed = false; + await nextTick(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + + wrapper.vm.sidebarState.isCollapsed = true; + await nextTick(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('pressing ESC key', () => { + beforeEach(() => { + createWrapper({ sidebarState: { isCollapsed: false, isPeekable: true } }); + }); + + const ESC_KEY = 27; + it('collapses sidebar when sidebar is in overlay mode', () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + findSidebar().trigger('keydown', { keyCode: ESC_KEY }); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalled(); + }); + + it('does nothing when sidebar is in peek mode', () => { + findHoverPeekBehavior().vm.$emit('change', STATE_OPEN); + + findSidebar().trigger('keydown', { keyCode: ESC_KEY }); + expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); + }); + + it('does nothing when sidebar is not overlapping', () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(xl); + + findSidebar().trigger('keydown', { keyCode: ESC_KEY }); + expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); + }); + }); }); -- GitLab