blob: 02ac933c038c92e5879f567ba65efc09bd571d92 [file] [log] [blame] [edit]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {DisposableStack} from '../base/disposable_stack';
import {toHTMLElement} from '../base/dom_utils';
import {DragGestureHandler} from '../base/drag_gesture_handler';
import {assertExists, assertUnreachable} from '../base/logging';
import {Button, ButtonBar} from './button';
export enum SplitPanelDrawerVisibility {
VISIBLE,
FULLSCREEN,
COLLAPSED,
}
export interface SplitPanelAttrs {
// Content to display on the handle.
readonly handleContent?: m.Children;
// Content to display inside the drawer.
readonly drawerContent?: m.Children;
// Whether the drawer is currently visible or not (when in controlled mode).
readonly visibility?: SplitPanelDrawerVisibility;
// Extra classes applied to the root element.
readonly className?: string;
// What height should the drawer be initially?
readonly startingHeight?: number;
// Called when the drawer visibility is changed.
onVisibilityChange?(visibility: SplitPanelDrawerVisibility): void;
}
/**
* A container that fills its parent container, splitting into two adjustable
* horizontal sections. The upper half is reserved for the main content and any
* children are placed here, and the lower half should be considered a drawer,
* the `drawerContent` attribute can be used to define what goes here.
*
* The drawer features a handle that can be dragged to adjust the height of the
* drawer, and also features buttons to maximize and minimise the drawer.
*
* Content can also optionally be displayed on the handle itself to the left of
* the buttons.
*
* The layout looks like this:
*
* ┌──────────────────────────────────────────────────────────────────┐
* │pf-split-panel │
* │┌────────────────────────────────────────────────────────────────┐|
* ││pf-split-panel__main ││
* |└────────────────────────────────────────────────────────────────┘|
* │┌────────────────────────────────────────────────────────────────┐|
* ││pf-split-panel__handle ││
* │|┌─────────────────────────────────┐┌───────────────────────────┐||
* |||pf-split-panel__handle-content ||pf-button-bar |||
* ||└─────────────────────────────────┘└───────────────────────────┘||
* |└────────────────────────────────────────────────────────────────┘|
* │┌────────────────────────────────────────────────────────────────┐|
* ││pf-split-panel__drawer ││
* |└────────────────────────────────────────────────────────────────┘|
* └──────────────────────────────────────────────────────────────────┘
*/
export class SplitPanel implements m.ClassComponent<SplitPanelAttrs> {
private readonly trash = new DisposableStack();
// The actual height of the vdom node. It matches resizableHeight if VISIBLE,
// 0 if COLLAPSED, fullscreenHeight if FULLSCREEN.
private height = 0;
// The height when the panel is 'VISIBLE'.
private resizableHeight: number;
// The height when the panel is 'FULLSCREEN'.
private fullscreenHeight = 0;
// Current visibility state (if not controlled).
private visibility = SplitPanelDrawerVisibility.VISIBLE;
constructor({attrs}: m.CVnode<SplitPanelAttrs>) {
this.resizableHeight = attrs.startingHeight ?? 100;
}
view({attrs, children}: m.CVnode<SplitPanelAttrs>) {
const {
visibility = this.visibility,
className,
handleContent,
onVisibilityChange,
drawerContent,
} = attrs;
switch (visibility) {
case SplitPanelDrawerVisibility.VISIBLE:
this.height = Math.min(
Math.max(this.resizableHeight, 0),
this.fullscreenHeight,
);
break;
case SplitPanelDrawerVisibility.FULLSCREEN:
this.height = this.fullscreenHeight;
break;
case SplitPanelDrawerVisibility.COLLAPSED:
this.height = 0;
break;
}
return m(
'.pf-split-panel',
{
className,
},
// Note: Using BEM class naming conventions: See https://getbem.com/
m('.pf-split-panel__main', children),
m(
'.pf-split-panel__handle',
m('.pf-split-panel__handle-content', handleContent),
this.renderTabResizeButtons(visibility, onVisibilityChange),
),
m(
'.pf-split-panel__drawer',
{
style: {height: `${this.height}px`},
},
drawerContent,
),
);
}
oncreate(vnode: m.VnodeDOM<SplitPanelAttrs, this>) {
let dragStartY = 0;
let heightWhenDragStarted = 0;
const handle = toHTMLElement(
assertExists(vnode.dom.querySelector('.pf-split-panel__handle')),
);
this.trash.use(
new DragGestureHandler(
handle,
/* onDrag */ (_x, y) => {
const deltaYSinceDragStart = dragStartY - y;
this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart;
m.redraw();
},
/* onDragStarted */ (_x, y) => {
this.resizableHeight = this.height;
heightWhenDragStarted = this.height;
dragStartY = y;
this.updatePanelVisibility(
SplitPanelDrawerVisibility.VISIBLE,
vnode.attrs.onVisibilityChange,
);
},
/* onDragFinished */ () => {},
),
);
const parent = assertExists(vnode.dom.parentElement);
this.fullscreenHeight = parent.clientHeight;
const resizeObs = new ResizeObserver(() => {
this.fullscreenHeight = parent.clientHeight;
m.redraw();
});
resizeObs.observe(parent);
this.trash.defer(() => resizeObs.disconnect());
}
onremove() {
this.trash.dispose();
}
private renderTabResizeButtons(
visibility: SplitPanelDrawerVisibility,
setVisibility?: (visibility: SplitPanelDrawerVisibility) => void,
): m.Child {
const isClosed = visibility === SplitPanelDrawerVisibility.COLLAPSED;
return m(
ButtonBar,
m(Button, {
title: 'Open fullscreen',
disabled: visibility === SplitPanelDrawerVisibility.FULLSCREEN,
icon: 'vertical_align_top',
compact: true,
onclick: () => {
this.updatePanelVisibility(
SplitPanelDrawerVisibility.FULLSCREEN,
setVisibility,
);
},
}),
m(Button, {
onclick: () => {
this.updatePanelVisibility(
toggleVisibility(visibility),
setVisibility,
);
},
title: isClosed ? 'Show panel' : 'Hide panel',
icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down',
compact: true,
}),
);
}
private updatePanelVisibility(
visibility: SplitPanelDrawerVisibility,
setVisibility?: (visibility: SplitPanelDrawerVisibility) => void,
) {
this.visibility = visibility;
setVisibility?.(visibility);
}
}
export function toggleVisibility(visibility: SplitPanelDrawerVisibility) {
switch (visibility) {
case SplitPanelDrawerVisibility.COLLAPSED:
case SplitPanelDrawerVisibility.FULLSCREEN:
return SplitPanelDrawerVisibility.VISIBLE;
case SplitPanelDrawerVisibility.VISIBLE:
return SplitPanelDrawerVisibility.COLLAPSED;
default:
assertUnreachable(visibility);
}
}