blob: 3ad08750be9d57b5660fbe9fc50cb17ce6e81597 [file] [edit]
// Copyright (C) 2025 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 './drawer_panel.scss';
import m from 'mithril';
import {assertUnreachable} from '../base/assert';
import {Gate, type MithrilEvent} from '../base/mithril_utils';
import {Button, ButtonBar} from './button';
import {classNames} from '../base/classnames';
import type {HTMLAttrs} from './common';
import {Icons} from '../base/semantic_icons';
export interface TabAttrs extends HTMLAttrs {
// Is this tab currently active?
readonly active?: boolean;
// Whether to show a close button on the tab.
readonly hasCloseButton?: boolean;
// What happens when the close button is clicked.
readonly onClose?: () => void;
}
export class Tab implements m.ClassComponent<TabAttrs> {
view({attrs, children}: m.CVnode<TabAttrs>): m.Children {
const {active, hasCloseButton, ...rest} = attrs;
return m(
'.pf-drawer-panel__tab',
{
...rest,
className: classNames(active && 'pf-drawer-panel__tab--active'),
onauxclick: () => {
attrs.onClose?.();
},
},
m('.pf-drawer-panel__tab-title', children),
hasCloseButton &&
m(Button, {
compact: true,
icon: Icons.Close,
onclick: (e) => {
e.stopPropagation();
attrs.onClose?.();
},
}),
);
}
}
export interface DrawerTab {
// Unique identifier for the tab.
readonly key: string;
// Content to display in the tab handle.
readonly title: m.Children;
// Content to display in the drawer when this tab is active.
readonly content: m.Children;
// Whether to show a close button on the tab.
readonly closable?: boolean;
}
export enum DrawerPanelVisibility {
VISIBLE,
FULLSCREEN,
COLLAPSED,
}
export interface DrawerPanelAttrs {
// Content to display in the main area (above the drawer).
readonly mainContent?: m.Children;
// Content to put to the left of the tabs on the handle.
readonly leftHandleContent?: m.Children;
// ===== Simple mode (no tab bar) =====
// Content to display inside the drawer.
readonly drawerContent?: m.Children;
// ===== Tabs mode (with tab bar) =====
// If provided, ignores drawerContent and renders tabs instead.
readonly tabs?: DrawerTab[];
// The currently active tab key (controlled mode).
// If not provided, the component manages its own state (uncontrolled mode).
readonly activeTabKey?: string;
// Called when a tab is clicked.
onTabChange?(key: string): void;
// Called when a tab's close button is clicked.
onTabClose?(key: string): void;
// ===== Common options =====
// Whether the drawer is currently visible or not (when in controlled mode).
readonly visibility?: DrawerPanelVisibility;
// 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: DrawerPanelVisibility): void;
}
/**
* A container that fills its parent container, with a main content area and a
* collapsible drawer at the bottom. The main content is specified via the
* `mainContent` attribute, and the drawer content via `drawerContent`.
*
* 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-drawer-panel │
* │┌─────────────────────────────────────────────────────────────────┐|
* ││pf-drawer-panel__main ││
* |└─────────────────────────────────────────────────────────────────┘|
* │┌─────────────────────────────────────────────────────────────────┐|
* ││pf-drawer-panel__handle ││
* │|┌─────────────────┐┌──────────────────────┐┌────────────────────┐||
* |||leftHandleContent||.pf-drawer-panel__tabs||.pf-button-bar |||
* ||└─────────────────┘└──────────────────────┘└────────────────────┘||
* |└─────────────────────────────────────────────────────────────────┘|
* │┌─────────────────────────────────────────────────────────────────┐|
* ││pf-drawer-panel__drawer ││
* |└─────────────────────────────────────────────────────────────────┘|
* └───────────────────────────────────────────────────────────────────┘
*/
export class DrawerPanel implements m.ClassComponent<DrawerPanelAttrs> {
// 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 = DrawerPanelVisibility.VISIBLE;
// Current active tab key (for uncontrolled mode).
private internalActiveTab?: string;
// For pointer capture drag handling
private handleElement?: HTMLElement;
private dragStartY?: number;
private heightWhenDragStarted = 0;
private resizeObserver?: ResizeObserver;
constructor({attrs}: m.CVnode<DrawerPanelAttrs>) {
this.resizableHeight = attrs.startingHeight ?? 100;
}
view({attrs}: m.CVnode<DrawerPanelAttrs>) {
const {
mainContent,
leftHandleContent,
drawerContent,
tabs,
activeTabKey,
onTabChange,
onTabClose,
visibility = this.visibility,
className,
onVisibilityChange,
} = attrs;
switch (visibility) {
case DrawerPanelVisibility.VISIBLE:
this.height = Math.min(
Math.max(this.resizableHeight, 0),
this.fullscreenHeight,
);
break;
case DrawerPanelVisibility.FULLSCREEN:
this.height = this.fullscreenHeight;
break;
case DrawerPanelVisibility.COLLAPSED:
this.height = 0;
break;
}
// Determine mode: tabs mode if tabs array is provided and non-empty
const isTabsMode = tabs !== undefined && tabs.length > 0;
// Get active tab key (controlled or uncontrolled)
const activeKey = isTabsMode
? activeTabKey ?? this.internalActiveTab ?? tabs[0].key
: undefined;
// Render tabs UI and drawer content based on mode
const tabsUI = isTabsMode
? this.renderTabs(tabs, activeKey!, onTabChange, onTabClose)
: undefined;
const drawer = isTabsMode
? this.renderTabContent(tabs, activeKey!)
: drawerContent;
return m(
'.pf-drawer-panel',
{
className,
},
m('.pf-drawer-panel__main', mainContent),
m(
'.pf-drawer-panel__handle',
{
oncontextmenu: (e: Event) => e.preventDefault(),
onpointerdown: (e: PointerEvent) => this.onPointerDown(e, attrs),
onpointermove: (e: MithrilEvent<PointerEvent>) =>
this.onPointerMove(e),
onpointerup: (e: PointerEvent) => this.onPointerUp(e),
onpointercancel: (e: PointerEvent) => this.onPointerCancel(e),
onpointercapturelost: (e: PointerEvent) =>
this.onPointerCaptureLost(e),
},
[
leftHandleContent,
m('.pf-drawer-panel__tabs', tabsUI),
m('.pf-drawer-panel__spacer'),
this.renderTabResizeButtons(visibility, onVisibilityChange),
],
),
m(
'.pf-drawer-panel__drawer',
{
style: {height: `${this.height}px`},
},
drawer,
),
);
}
private renderTabs(
tabs: DrawerTab[],
activeKey: string,
onTabChange?: (key: string) => void,
onTabClose?: (key: string) => void,
): m.Children {
return tabs.map((tab) =>
m(
Tab,
{
active: tab.key === activeKey,
hasCloseButton: tab.closable,
onclick: () => {
this.internalActiveTab = tab.key;
onTabChange?.(tab.key);
},
onClose: () => onTabClose?.(tab.key),
},
tab.title,
),
);
}
private renderTabContent(tabs: DrawerTab[], activeKey: string): m.Children {
return tabs.map((tab) =>
m(Gate, {open: tab.key === activeKey}, tab.content),
);
}
oncreate(vnode: m.VnodeDOM<DrawerPanelAttrs, this>) {
const parent = vnode.dom.parentElement;
if (parent) {
this.fullscreenHeight = parent.clientHeight;
this.resizeObserver = new ResizeObserver(() => {
this.fullscreenHeight = parent.clientHeight;
m.redraw();
});
this.resizeObserver.observe(parent);
}
}
onremove() {
this.resizeObserver?.disconnect();
}
private endDrag(pointerId: number) {
if (this.dragStartY !== undefined) {
this.dragStartY = undefined;
if (this.handleElement?.hasPointerCapture(pointerId)) {
this.handleElement.releasePointerCapture(pointerId);
}
}
}
private onPointerDown(e: PointerEvent, attrs: DrawerPanelAttrs) {
// Only start drag if the handle itself or the spacer is targeted
const target = e.target as HTMLElement;
const isHandle = target === e.currentTarget;
const isSpacer = target.classList.contains('pf-drawer-panel__spacer');
if (!isHandle && !isSpacer) {
return;
}
this.handleElement = e.currentTarget as HTMLElement;
this.handleElement.setPointerCapture(e.pointerId);
this.dragStartY = e.clientY;
this.resizableHeight = this.height;
this.heightWhenDragStarted = this.height;
this.updatePanelVisibility(
DrawerPanelVisibility.VISIBLE,
attrs.onVisibilityChange,
);
e.stopPropagation();
}
private onPointerMove(e: MithrilEvent<PointerEvent>) {
e.redraw = false;
if (this.dragStartY !== undefined) {
const deltaY = this.dragStartY - e.clientY;
this.resizableHeight = this.heightWhenDragStarted + deltaY;
m.redraw();
}
}
private onPointerUp(e: PointerEvent) {
this.endDrag(e.pointerId);
}
private onPointerCancel(e: PointerEvent) {
this.endDrag(e.pointerId);
}
private onPointerCaptureLost(e: PointerEvent) {
this.endDrag(e.pointerId);
}
private renderTabResizeButtons(
visibility: DrawerPanelVisibility,
setVisibility?: (visibility: DrawerPanelVisibility) => void,
): m.Child {
const isClosed = visibility === DrawerPanelVisibility.COLLAPSED;
return m(
ButtonBar,
m(Button, {
title: 'Open fullscreen',
disabled: visibility === DrawerPanelVisibility.FULLSCREEN,
icon: 'vertical_align_top',
onclick: () => {
this.updatePanelVisibility(
DrawerPanelVisibility.FULLSCREEN,
setVisibility,
);
},
}),
m(Button, {
onclick: () => {
this.updatePanelVisibility(
toggleVisibility(visibility),
setVisibility,
);
},
title: isClosed ? 'Show panel' : 'Hide panel',
icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down',
}),
);
}
private updatePanelVisibility(
visibility: DrawerPanelVisibility,
setVisibility?: (visibility: DrawerPanelVisibility) => void,
) {
this.visibility = visibility;
setVisibility?.(visibility);
}
}
export function toggleVisibility(visibility: DrawerPanelVisibility) {
switch (visibility) {
case DrawerPanelVisibility.COLLAPSED:
case DrawerPanelVisibility.FULLSCREEN:
return DrawerPanelVisibility.VISIBLE;
case DrawerPanelVisibility.VISIBLE:
return DrawerPanelVisibility.COLLAPSED;
default:
assertUnreachable(visibility);
}
}