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