Support resizable & position-configurable diff sidebar

Allow plugins to specify whether sidebar content in gr-diff-view should
be placed on the left or right side via the Web Component's static
sidebarPosition property or sidebar-position attribute.

Integrate gr-content-with-sidebar in gr-diff-view wrapping the diff
container so sidebar resizing works smoothly without interfering with
sticky headers.

TAG=agy
CONV=e67a39e1-770b-4508-89ec-e8cec81ba0d6

Release-Notes: Support user-resizable and plugin-configurable left/right sidebar positioning in gr-diff-view.
Google-Bug-Id: b/537885178
Change-Id: I8178f76396a5951caea2509d141e1271b2faf5e7
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index ed56ad3..cf151d4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -19,6 +19,8 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
@@ -88,7 +90,6 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
-import {styleMap} from 'lit/directives/style-map.js';
 import {keyed} from 'lit/directives/keyed.js';
 import {
   ChangeChildView,
@@ -128,6 +129,8 @@
 
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   /**
    * Fired when user tries to navigate away while comments are pending save.
    *
@@ -154,11 +157,6 @@
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  @query('.sidebarAnchor')
-  sidebarAnchor?: HTMLDivElement;
-
-  @state() private sidebarHeight = 0;
-
   // Private but used in tests.
   @state()
   get patchRange(): PatchRange | undefined {
@@ -527,7 +525,6 @@
         :host {
           display: block;
           background-color: var(--view-background-color);
-          --sidebar-width: 300px;
         }
         .hidden {
           display: none;
@@ -703,24 +700,14 @@
         :host(.hideCheckCodePointers) {
           --gr-check-code-pointers-display: none;
         }
-        .diffContainer.sidebarOpen {
-          margin-left: var(--sidebar-width);
-        }
         .sidebarTriggerContainer {
           display: inline-block;
           margin-right: var(--spacing-m);
         }
-        .sidebarAnchor {
-          height: 0;
-          width: 0;
-          overflow: visible;
-        }
         .sidebarContents {
           background: var(--background-color-secondary);
-          width: var(--sidebar-width);
-          border: var(--spacing-xxs) solid var(--border-color);
-          border-left: 0;
-          overflow: auto;
+          box-sizing: border-box;
+          height: 100%;
         }
         md-checkbox {
           --md-checkbox-container-size: 15px;
@@ -750,8 +737,6 @@
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
     this.cursor = new GrDiffCursor();
     if (this.diffHost) this.reInitCursor();
-    window.addEventListener('scroll', this.updateSidebarHeight);
-    window.addEventListener('resize', this.updateSidebarHeight);
     this.getUserModel()
       .preferences$.pipe(
         map(p => p.diff_page_sidebar),
@@ -769,8 +754,6 @@
 
   override disconnectedCallback() {
     this.cursor?.dispose();
-    window.removeEventListener('scroll', this.updateSidebarHeight);
-    window.removeEventListener('resize', this.updateSidebarHeight);
     super.disconnectedCallback();
   }
 
@@ -780,13 +763,6 @@
     this.cursor?.reInitCursor();
   }
 
-  private readonly updateSidebarHeight = () => {
-    if (this.sidebarAnchor) {
-      this.sidebarHeight =
-        window.innerHeight - this.sidebarAnchor.getBoundingClientRect().bottom;
-    }
-  };
-
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
@@ -845,7 +821,6 @@
         this.patchRange
       );
     }
-    this.updateSidebarHeight();
   }
 
   override render() {
@@ -869,34 +844,41 @@
           </div>
         `
       )}
-      <div
-        class=${classMap({
-          diffContainer: true,
-          sidebarOpen: !!this.shownSidebar,
-          hidden: !!this.file?.diffs_too_expensive_to_compute,
-        })}
+      <gr-content-with-sidebar
+        .side=${this.getSidebarSide()}
+        .hideSide=${!this.shownSidebar}
       >
-        <gr-endpoint-decorator name="diff-content">
-          <gr-diff-host
-            id="diffHost"
-            .changeNum=${this.changeNum}
-            .change=${this.change}
-            .patchRange=${this.patchRange}
-            .file=${file}
-            .lineOfInterest=${this.getLineOfInterest()}
-            .path=${this.path}
-            .projectName=${this.change?.project}
-            @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-            @comment-anchor-tap=${this.onCommentAnchorTap}
-            @line-selected=${this.onLineSelected}
-            @diff-changed=${this.onDiffChanged}
-            @edit-weblinks-changed=${this.onEditWeblinksChanged}
-            @files-weblinks-changed=${this.onFilesWeblinksChanged}
-            @render=${this.reInitCursor}
-          >
-          </gr-diff-host>
-        </gr-endpoint-decorator>
-      </div>
+        <div
+          slot="main"
+          class=${classMap({
+            diffContainer: true,
+            sidebarOpen: !!this.shownSidebar,
+            hidden: !!this.file?.diffs_too_expensive_to_compute,
+          })}
+        >
+          <gr-endpoint-decorator name="diff-content">
+            <gr-diff-host
+              id="diffHost"
+              .changeNum=${this.changeNum}
+              .change=${this.change}
+              .patchRange=${this.patchRange}
+              .file=${file}
+              .lineOfInterest=${this.getLineOfInterest()}
+              .path=${this.path}
+              .projectName=${this.change?.project}
+              @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+              @comment-anchor-tap=${this.onCommentAnchorTap}
+              @line-selected=${this.onLineSelected}
+              @diff-changed=${this.onDiffChanged}
+              @edit-weblinks-changed=${this.onEditWeblinksChanged}
+              @files-weblinks-changed=${this.onFilesWeblinksChanged}
+              @render=${this.reInitCursor}
+            >
+            </gr-diff-host>
+          </gr-endpoint-decorator>
+        </div>
+        <div slot="side">${this.renderSidebarContent()}</div>
+      </gr-content-with-sidebar>
       ${this.renderDialogs()}
     `;
   }
@@ -921,7 +903,6 @@
           >&gt;</a
         >
       </div>
-      ${this.renderSidebarContent()}
     </div>`;
   }
 
@@ -1024,80 +1005,94 @@
     `;
   }
 
+  private getSidebarSide(): 'left' | 'right' {
+    if (!this.shownSidebar) {
+      return 'left';
+    }
+    const details = this.getPluginLoader().pluginEndPoints.getDetails(
+      `sidebarContent-${this.shownSidebar}`
+    );
+    for (const info of details) {
+      if (info.moduleName) {
+        const customElement = customElements.get(info.moduleName) as
+          | (CustomElementConstructor & {sidebarPosition?: string})
+          | undefined;
+        if (customElement?.sidebarPosition === 'right') {
+          return 'right';
+        }
+      }
+    }
+    return 'left';
+  }
+
   private renderSidebarContent() {
-    // Always renders the 0x0px .sidebarAnchor div for scroll measurements.
     return html`
-      <div class="sidebarAnchor">
-        ${when(this.shownSidebar !== undefined, () =>
-          keyed(
-            this.shownSidebar,
-            html`
-              <div
-                class="sidebarContents"
-                style=${styleMap({height: `${this.sidebarHeight}px`})}
+      ${when(this.shownSidebar !== undefined, () =>
+        keyed(
+          this.shownSidebar,
+          html`
+            <div class="sidebarContents">
+              <gr-endpoint-decorator
+                name=${`sidebarContent-${this.shownSidebar}`}
               >
-                <gr-endpoint-decorator
-                  name=${`sidebarContent-${this.shownSidebar}`}
+                <gr-endpoint-param
+                  name="change"
+                  .value=${this.change}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="path"
+                  .value=${this.path}
+                ></gr-endpoint-param>
+                <!-- current diff path and, in case of rename, previous path -->
+                <gr-endpoint-param
+                  name="fileRange"
+                  .value=${this.getFileRange()}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="basePatchNum"
+                  .value=${this.basePatchNum}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="patchNum"
+                  .value=${this.patchNum}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="content"
+                  .value=${this.diff}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="cursor"
+                  .value=${this.cursor}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="diff"
+                  .value=${this.diffHost?.diffElement}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="comments"
+                  .value=${this.commentsForPath}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="onClose"
+                  .value=${(pluginName: string) => {
+                    // Only close the sidebar if that particular sidebar is
+                    // still open. An async onClose callback should not close a
+                    // different sidebar.
+                    if (this.shownSidebar !== pluginName) {
+                      return;
+                    }
+                    this.shownSidebar = undefined;
+                    this.getUserModel().updatePreferences({
+                      diff_page_sidebar: 'NONE',
+                    });
+                  }}
                 >
-                  <gr-endpoint-param
-                    name="change"
-                    .value=${this.change}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="path"
-                    .value=${this.path}
-                  ></gr-endpoint-param>
-                  <!-- current diff path and, in case of rename, previous path -->
-                  <gr-endpoint-param
-                    name="fileRange"
-                    .value=${this.getFileRange()}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="basePatchNum"
-                    .value=${this.basePatchNum}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="patchNum"
-                    .value=${this.patchNum}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="content"
-                    .value=${this.diff}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="cursor"
-                    .value=${this.cursor}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="diff"
-                    .value=${this.diffHost?.diffElement}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="comments"
-                    .value=${this.commentsForPath}
-                  ></gr-endpoint-param>
-                  <gr-endpoint-param
-                    name="onClose"
-                    .value=${(pluginName: string) => {
-                      // Only close the sidebar if that particular sidebar is
-                      // still open. An async onClose callback should not close a
-                      // different sidebar.
-                      if (this.shownSidebar !== pluginName) {
-                        return;
-                      }
-                      this.shownSidebar = undefined;
-                      this.getUserModel().updatePreferences({
-                        diff_page_sidebar: 'NONE',
-                      });
-                    }}
-                  >
-                  </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </div>
-            `
-          )
-        )}
-      </div>
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          `
+        )
+      )}
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index d1e2071..daed454 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -51,7 +51,9 @@
 } from '../../../types/common';
 import {CursorMoveResult} from '../../../api/core';
 import {Side} from '../../../api/diff';
+import {PluginApi} from '../../../api/plugin';
 import {Files, GrDiffView} from './gr-diff-view';
+import {GrContentWithSidebar} from '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {SinonFakeTimers, SinonStub, SinonStubbedMember} from 'sinon';
 import {
@@ -398,14 +400,16 @@
                 >
               </a>
             </div>
-            <div class="sidebarAnchor"></div>
           </div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <div class="diffContainer">
-            <gr-endpoint-decorator name="diff-content">
-              <gr-diff-host id="diffHost"> </gr-diff-host>
-            </gr-endpoint-decorator>
-          </div>
+          <gr-content-with-sidebar>
+            <div class="diffContainer" slot="main">
+              <gr-endpoint-decorator name="diff-content">
+                <gr-diff-host id="diffHost"> </gr-diff-host>
+              </gr-endpoint-decorator>
+            </div>
+            <div slot="side"></div>
+          </gr-content-with-sidebar>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
@@ -2289,6 +2293,48 @@
         );
         assert.notEqual(oldDecorator, newDecorator);
       });
+
+      test('defaults to left sidebar side when no sidebarPosition property set', async () => {
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'left-sidebar';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.isNotNull(contentWithSidebar);
+        assert.equal(contentWithSidebar?.side, 'left');
+      });
+
+      test('detects right sidebar side when static sidebarPosition === "right"', async () => {
+        class RightSidebar extends HTMLElement {
+          static sidebarPosition = 'right';
+        }
+
+        customElements.define('right-sidebar-element', RightSidebar);
+
+        let plugin!: PluginApi;
+        window.Gerrit.install(
+          p => (plugin = p),
+          '0.1',
+          'http://test.com/plugins/testplugin/static/test.js'
+        );
+        plugin.registerDynamicCustomComponent(
+          'sidebarContent',
+          'right-sidebar-element'
+        );
+
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'testplugin';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.equal(contentWithSidebar?.side, 'right');
+      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
index 0e0638e..869f9df 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
@@ -4,14 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {styleMap} from 'lit/directives/style-map.js';
 
-const SIDEBAR_MIN_WIDTH = 400;
+const SIDEBAR_MIN_WIDTH = 250;
 
 /**
  * A component that displays content in a main area and a resizable sidebar.
- * The sidebar can be toggled between hidden and visible.
+ * The sidebar can be toggled between hidden and visible and positioned on the left or right.
  *
  * slot main - The content to be displayed in the main area.
  * slot side - The content to be displayed in the sidebar.
@@ -21,11 +21,17 @@
   @query('.sidebar-wrapper') sidebarWrapper?: HTMLElement;
 
   @state()
-  private sidebarWidthPx = SIDEBAR_MIN_WIDTH;
+  private sidebarWidthPx = 400;
 
   @property()
   hideSide = true;
 
+  @property()
+  side: 'left' | 'right' = 'right';
+
+  @property({type: Number})
+  minWidth = SIDEBAR_MIN_WIDTH;
+
   private isSidebarResizing = false;
 
   private sidebarResizingStartPosPx = 0;
@@ -43,22 +49,29 @@
         :host {
           display: block;
           position: relative;
-          --sidebar-height: calc(100vh - var(--sidebar-top));
+          --sidebar-height: calc(100vh - var(--sidebar-top, 0px));
         }
         .sidebar-wrapper {
           z-index: 50;
           position: absolute;
           display: flex;
           top: 0;
-          bottom: calc(0px - var(--sidebar-bottom-overflow));
-          right: 0;
-          min-width: 400px;
+          bottom: calc(0px - var(--sidebar-bottom-overflow, 0px));
+          min-width: 250px;
           max-width: 100%;
           background-color: var(--background-color-secondary);
         }
+        .sidebar-wrapper.right {
+          right: 0;
+          left: auto;
+        }
+        .sidebar-wrapper.left {
+          left: 0;
+          right: auto;
+        }
         .sidebar {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           box-sizing: border-box;
           overflow: auto;
@@ -67,35 +80,55 @@
         }
         .resizer-wrapper {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           z-index: 51;
         }
         .resizer {
           background-color: var(--background-color-secondary);
           width: 7px;
-          border-left: 1px solid var(--border-color);
           cursor: ew-resize;
           position: absolute;
           top: 0;
           bottom: 0;
-          left: -7px;
           box-sizing: border-box;
         }
-        .resizer:hover {
+        .resizer.right-side {
+          left: -7px;
+          border-left: 1px solid var(--border-color);
+        }
+        .resizer.right-side:hover {
           background-color: var(--background-color-tertiary);
           width: 11px;
           left: -9px;
         }
+        .resizer.left-side {
+          right: -7px;
+          border-right: 1px solid var(--border-color);
+        }
+        .resizer.left-side:hover {
+          background-color: var(--background-color-tertiary);
+          width: 11px;
+          right: -9px;
+        }
       `,
     ];
   }
 
   override render() {
     const widthPx = this.hideSide ? 0 : this.sidebarWidthPx;
+    const mainStyle =
+      this.side === 'left'
+        ? styleMap({
+            marginLeft: `${widthPx}px`,
+            width: `calc(100% - ${widthPx}px)`,
+          })
+        : styleMap({
+            width: `calc(100% - ${widthPx}px)`,
+          });
     return html`
       <div>
-        <div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}>
+        <div style=${mainStyle}>
           <slot name="main"></slot>
         </div>
         ${this.renderSidebar()}
@@ -105,25 +138,34 @@
 
   private renderSidebar() {
     if (this.hideSide) return;
+    const sideClass = this.side === 'left' ? 'left' : 'right';
+    const resizerClass = this.side === 'left' ? 'left-side' : 'right-side';
     return html`
       <div
-        class="sidebar-wrapper"
+        class="sidebar-wrapper ${sideClass}"
         style=${styleMap({width: `${this.sidebarWidthPx}px`})}
       >
-        <div class="resizer-wrapper">
-          <div
-            class="resizer"
-            role="separator"
-            aria-orientation="vertical"
-            aria-valuenow=${this.sidebarWidthPx}
-            aria-label="Resize sidebar"
-            tabindex="0"
-            @mousedown=${this.startSidebarResize}
-          ></div>
-        </div>
+        ${this.side === 'right' ? this.renderResizer(resizerClass) : nothing}
         <div class="sidebar">
           <slot name="side"></slot>
         </div>
+        ${this.side === 'left' ? this.renderResizer(resizerClass) : nothing}
+      </div>
+    `;
+  }
+
+  private renderResizer(resizerClass: string) {
+    return html`
+      <div class="resizer-wrapper">
+        <div
+          class="resizer ${resizerClass}"
+          role="separator"
+          aria-orientation="vertical"
+          aria-valuenow=${this.sidebarWidthPx}
+          aria-label="Resize sidebar"
+          tabindex="0"
+          @mousedown=${this.startSidebarResize}
+        ></div>
       </div>
     `;
   }
@@ -157,10 +199,11 @@
     if (!this.isSidebarResizing || event.buttons === 0) return;
 
     const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx;
-    this.sidebarWidthPx = Math.max(
-      this.sidebarResizingStartWidthPx - widthDiffPx,
-      SIDEBAR_MIN_WIDTH
-    );
+    const rawWidth =
+      this.side === 'right'
+        ? this.sidebarResizingStartWidthPx - widthDiffPx
+        : this.sidebarResizingStartWidthPx + widthDiffPx;
+    this.sidebarWidthPx = Math.max(rawWidth, this.minWidth);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
index 52ea31d..7ed19b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
@@ -44,8 +44,23 @@
     await element.updateComplete;
   });
 
-  test('screenshot', async () => {
-    await visualDiff(wrapper, 'gr-content-with-sidebar');
-    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar');
+  test('screenshot right sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'right';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-right');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-right');
+  });
+
+  test('screenshot left sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'left';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-left');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-left');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
index 7905c0a..626e3dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
@@ -34,8 +34,9 @@
     );
   });
 
-  test('renders sidebar', async () => {
+  test('renders right sidebar', async () => {
     element.hideSide = false;
+    element.side = 'right';
     await element.updateComplete;
 
     assert.shadowDom.equal(
@@ -45,13 +46,13 @@
           <div style="width: calc(100% - 400px);">
             <slot name="main"> </slot>
           </div>
-          <div class="sidebar-wrapper" style="width:400px;">
+          <div class="right sidebar-wrapper" style="width:400px;">
             <div class="resizer-wrapper">
               <div
                 aria-label="Resize sidebar"
                 aria-orientation="vertical"
                 aria-valuenow="400"
-                class="resizer"
+                class="right-side resizer"
                 role="separator"
                 tabindex="0"
               ></div>
@@ -64,4 +65,36 @@
       `
     );
   });
+
+  test('renders left sidebar', async () => {
+    element.hideSide = false;
+    element.side = 'left';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <div style="width: calc(100% - 400px); margin-left: 400px;">
+            <slot name="main"> </slot>
+          </div>
+          <div class="left sidebar-wrapper" style="width:400px;">
+            <div class="sidebar">
+              <slot name="side"> </slot>
+            </div>
+            <div class="resizer-wrapper">
+              <div
+                aria-label="Resize sidebar"
+                aria-orientation="vertical"
+                aria-valuenow="400"
+                class="left-side resizer"
+                role="separator"
+                tabindex="0"
+              ></div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
new file mode 100644
index 0000000..0f4857e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
new file mode 100644
index 0000000..2464d3f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
new file mode 100644
index 0000000..238c3fb
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
new file mode 100644
index 0000000..ff32fb3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
Binary files differ