| // 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); |
| } |
| } |