blob: f87914fbf0a019cda011563e5e93a3b6bc1ed7b1 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement, property, query, state} from 'lit/decorators.js';
import {css, html, LitElement} from 'lit';
import {styleMap} from 'lit/directives/style-map.js';
const SIDEBAR_MIN_WIDTH = 300;
/**
* A component that displays content in a main area and a resizable sidebar.
* The sidebar can be toggled between hidden and visible.
*
* slot main - The content to be displayed in the main area.
* slot side - The content to be displayed in the sidebar.
*/
@customElement('gr-content-with-sidebar')
export class GrContentWithSidebar extends LitElement {
@query('.sidebar-wrapper') sidebarWrapper?: HTMLElement;
@state()
private sidebarWidthPx = SIDEBAR_MIN_WIDTH;
@property()
hideSide = true;
private isSidebarResizing = false;
private sidebarResizingStartPosPx = 0;
private sidebarResizingStartWidthPx = 0;
private readonly boundResizeSidebar = (e: MouseEvent) =>
this.resizeSidebar(e);
private readonly boundStopSidebarResize = () => this.stopSidebarResize();
static override get styles() {
return [
css`
:host {
display: block;
--sidebar-height: calc(100vh - var(--sidebar-top));
}
.sidebar-wrapper {
z-index: 50;
position: absolute;
display: flex;
top: 0;
bottom: calc(0px - var(--sidebar-bottom-overflow));
right: 0;
min-width: 300px;
max-width: 100%;
background-color: var(--background-color-secondary);
}
.sidebar {
position: sticky;
top: var(--sidebar-top);
height: var(--sidebar-height);
box-sizing: border-box;
overflow: auto;
flex-grow: 1;
padding: var(--spacing-l);
font-size: 14px;
}
.resizer-wrapper {
position: sticky;
top: var(--sidebar-top);
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 {
background-color: var(--background-color-tertiary);
width: 11px;
left: -9px;
}
`,
];
}
override render() {
const widthPx = this.hideSide ? 0 : this.sidebarWidthPx;
return html`
<div>
<div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}>
<slot name="main"></slot>
</div>
${this.renderSidebar()}
</div>
`;
}
private renderSidebar() {
if (this.hideSide) return;
return html`
<div
class="sidebar-wrapper"
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>
<div class="sidebar">
<slot name="side"></slot>
</div>
</div>
`;
}
private startSidebarResize(event: MouseEvent) {
if (this.isSidebarResizing) return;
// Disable user selection while resizing.
document.body.style.setProperty('user-select', 'none');
this.isSidebarResizing = true;
this.sidebarResizingStartPosPx = event.clientX;
this.sidebarResizingStartWidthPx =
this.sidebarWrapper!.getBoundingClientRect().width;
window.addEventListener('mousemove', this.boundResizeSidebar);
window.addEventListener('mouseup', this.boundStopSidebarResize);
}
private stopSidebarResize() {
if (!this.isSidebarResizing) return;
// Re-enable user selection when resizing is done.
document.body.style.setProperty('user-select', 'auto');
this.isSidebarResizing = false;
this.sidebarResizingStartPosPx = 0;
this.sidebarResizingStartWidthPx = 0;
window.removeEventListener('mousemove', this.boundResizeSidebar);
window.removeEventListener('mouseup', this.boundStopSidebarResize);
}
private resizeSidebar(event: MouseEvent) {
if (!this.isSidebarResizing || event.buttons === 0) return;
const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx;
this.sidebarWidthPx = Math.max(
this.sidebarResizingStartWidthPx - widthDiffPx,
SIDEBAR_MIN_WIDTH
);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-content-with-sidebar': GrContentWithSidebar;
}
}