• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {DisposableStack} from '../base/disposable_stack';
17import {toHTMLElement} from '../base/dom_utils';
18import {DragGestureHandler} from '../base/drag_gesture_handler';
19import {assertExists, assertUnreachable} from '../base/logging';
20import {Button, ButtonBar} from './button';
21
22export enum SplitPanelDrawerVisibility {
23  VISIBLE,
24  FULLSCREEN,
25  COLLAPSED,
26}
27
28export interface SplitPanelAttrs {
29  // Content to display on the handle.
30  readonly handleContent?: m.Children;
31
32  // Content to display inside the drawer.
33  readonly drawerContent?: m.Children;
34
35  // Whether the drawer is currently visible or not (when in controlled mode).
36  readonly visibility?: SplitPanelDrawerVisibility;
37
38  // Extra classes applied to the root element.
39  readonly className?: string;
40
41  // What height should the drawer be initially?
42  readonly startingHeight?: number;
43
44  // Called when the drawer visibility is changed.
45  onVisibilityChange?(visibility: SplitPanelDrawerVisibility): void;
46}
47
48/**
49 * A container that fills its parent container, splitting into two adjustable
50 * horizontal sections. The upper half is reserved for the main content and any
51 * children are placed here, and the lower half should be considered a drawer,
52 * the `drawerContent` attribute can be used to define what goes here.
53 *
54 * The drawer features a handle that can be dragged to adjust the height of the
55 * drawer, and also features buttons to maximize and minimise the drawer.
56 *
57 * Content can also optionally be displayed on the handle itself to the left of
58 * the buttons.
59 *
60 * The layout looks like this:
61 *
62 * ┌──────────────────────────────────────────────────────────────────┐
63 * │pf-split-panel                                                    │
64 * │┌────────────────────────────────────────────────────────────────┐|
65 * ││pf-split-panel__main                                            ││
66 * |└────────────────────────────────────────────────────────────────┘|
67 * │┌────────────────────────────────────────────────────────────────┐|
68 * ││pf-split-panel__handle                                          ││
69 * │|┌─────────────────────────────────┐┌───────────────────────────┐||
70 * |||pf-split-panel__handle-content   ||pf-button-bar              |||
71 * ||└─────────────────────────────────┘└───────────────────────────┘||
72 * |└────────────────────────────────────────────────────────────────┘|
73 * │┌────────────────────────────────────────────────────────────────┐|
74 * ││pf-split-panel__drawer                                          ││
75 * |└────────────────────────────────────────────────────────────────┘|
76 * └──────────────────────────────────────────────────────────────────┘
77 */
78export class SplitPanel implements m.ClassComponent<SplitPanelAttrs> {
79  private readonly trash = new DisposableStack();
80
81  // The actual height of the vdom node. It matches resizableHeight if VISIBLE,
82  // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN.
83  private height = 0;
84
85  // The height when the panel is 'VISIBLE'.
86  private resizableHeight: number;
87
88  // The height when the panel is 'FULLSCREEN'.
89  private fullscreenHeight = 0;
90
91  // Current visibility state (if not controlled).
92  private visibility = SplitPanelDrawerVisibility.VISIBLE;
93
94  constructor({attrs}: m.CVnode<SplitPanelAttrs>) {
95    this.resizableHeight = attrs.startingHeight ?? 100;
96  }
97
98  view({attrs, children}: m.CVnode<SplitPanelAttrs>) {
99    const {
100      visibility = this.visibility,
101      className,
102      handleContent,
103      onVisibilityChange,
104      drawerContent,
105    } = attrs;
106
107    switch (visibility) {
108      case SplitPanelDrawerVisibility.VISIBLE:
109        this.height = Math.min(
110          Math.max(this.resizableHeight, 0),
111          this.fullscreenHeight,
112        );
113        break;
114      case SplitPanelDrawerVisibility.FULLSCREEN:
115        this.height = this.fullscreenHeight;
116        break;
117      case SplitPanelDrawerVisibility.COLLAPSED:
118        this.height = 0;
119        break;
120    }
121
122    return m(
123      '.pf-split-panel',
124      {
125        className,
126      },
127      // Note: Using BEM class naming conventions: See https://getbem.com/
128      m('.pf-split-panel__main', children),
129      m(
130        '.pf-split-panel__handle',
131        m('.pf-split-panel__handle-content', handleContent),
132        this.renderTabResizeButtons(visibility, onVisibilityChange),
133      ),
134      m(
135        '.pf-split-panel__drawer',
136        {
137          style: {height: `${this.height}px`},
138        },
139        drawerContent,
140      ),
141    );
142  }
143
144  oncreate(vnode: m.VnodeDOM<SplitPanelAttrs, this>) {
145    let dragStartY = 0;
146    let heightWhenDragStarted = 0;
147
148    const handle = toHTMLElement(
149      assertExists(vnode.dom.querySelector('.pf-split-panel__handle')),
150    );
151
152    this.trash.use(
153      new DragGestureHandler(
154        handle,
155        /* onDrag */ (_x, y) => {
156          const deltaYSinceDragStart = dragStartY - y;
157          this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart;
158          m.redraw();
159        },
160        /* onDragStarted */ (_x, y) => {
161          this.resizableHeight = this.height;
162          heightWhenDragStarted = this.height;
163          dragStartY = y;
164          this.updatePanelVisibility(
165            SplitPanelDrawerVisibility.VISIBLE,
166            vnode.attrs.onVisibilityChange,
167          );
168        },
169        /* onDragFinished */ () => {},
170      ),
171    );
172
173    const parent = assertExists(vnode.dom.parentElement);
174    this.fullscreenHeight = parent.clientHeight;
175    const resizeObs = new ResizeObserver(() => {
176      this.fullscreenHeight = parent.clientHeight;
177      m.redraw();
178    });
179    resizeObs.observe(parent);
180    this.trash.defer(() => resizeObs.disconnect());
181  }
182
183  onremove() {
184    this.trash.dispose();
185  }
186
187  private renderTabResizeButtons(
188    visibility: SplitPanelDrawerVisibility,
189    setVisibility?: (visibility: SplitPanelDrawerVisibility) => void,
190  ): m.Child {
191    const isClosed = visibility === SplitPanelDrawerVisibility.COLLAPSED;
192    return m(
193      ButtonBar,
194      m(Button, {
195        title: 'Open fullscreen',
196        disabled: visibility === SplitPanelDrawerVisibility.FULLSCREEN,
197        icon: 'vertical_align_top',
198        compact: true,
199        onclick: () => {
200          this.updatePanelVisibility(
201            SplitPanelDrawerVisibility.FULLSCREEN,
202            setVisibility,
203          );
204        },
205      }),
206      m(Button, {
207        onclick: () => {
208          this.updatePanelVisibility(
209            toggleVisibility(visibility),
210            setVisibility,
211          );
212        },
213        title: isClosed ? 'Show panel' : 'Hide panel',
214        icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down',
215        compact: true,
216      }),
217    );
218  }
219
220  private updatePanelVisibility(
221    visibility: SplitPanelDrawerVisibility,
222    setVisibility?: (visibility: SplitPanelDrawerVisibility) => void,
223  ) {
224    this.visibility = visibility;
225    setVisibility?.(visibility);
226  }
227}
228
229export function toggleVisibility(visibility: SplitPanelDrawerVisibility) {
230  switch (visibility) {
231    case SplitPanelDrawerVisibility.COLLAPSED:
232    case SplitPanelDrawerVisibility.FULLSCREEN:
233      return SplitPanelDrawerVisibility.VISIBLE;
234    case SplitPanelDrawerVisibility.VISIBLE:
235      return SplitPanelDrawerVisibility.COLLAPSED;
236    default:
237      assertUnreachable(visibility);
238  }
239}
240