UNPKG

12.9 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { ArrayExt } from '@lumino/algorithm';
5import { Message } from '@lumino/messaging';
6import { AccordionLayout } from './accordionlayout';
7import { SplitLayout } from './splitlayout';
8import { SplitPanel } from './splitpanel';
9import { Title } from './title';
10import { Widget } from './widget';
11
12/**
13 * A panel which arranges its widgets into resizable sections separated by a title widget.
14 *
15 * #### Notes
16 * This class provides a convenience wrapper around [[AccordionLayout]].
17 */
18export class AccordionPanel extends SplitPanel {
19 /**
20 * Construct a new accordion panel.
21 *
22 * @param options - The options for initializing the accordion panel.
23 */
24 constructor(options: AccordionPanel.IOptions = {}) {
25 super({ ...options, layout: Private.createLayout(options) });
26 this.addClass('lm-AccordionPanel');
27 }
28
29 /**
30 * The renderer used by the accordion panel.
31 */
32 get renderer(): AccordionPanel.IRenderer {
33 return (this.layout as AccordionLayout).renderer;
34 }
35
36 /**
37 * The section title space.
38 *
39 * This is the height if the panel is vertical and the width if it is
40 * horizontal.
41 */
42 get titleSpace(): number {
43 return (this.layout as AccordionLayout).titleSpace;
44 }
45 set titleSpace(value: number) {
46 (this.layout as AccordionLayout).titleSpace = value;
47 }
48
49 /**
50 * A read-only array of the section titles in the panel.
51 */
52 get titles(): ReadonlyArray<HTMLElement> {
53 return (this.layout as AccordionLayout).titles;
54 }
55
56 /**
57 * Add a widget to the end of the panel.
58 *
59 * @param widget - The widget to add to the panel.
60 *
61 * #### Notes
62 * If the widget is already contained in the panel, it will be moved.
63 */
64 addWidget(widget: Widget): void {
65 super.addWidget(widget);
66 widget.title.changed.connect(this._onTitleChanged, this);
67 }
68
69 /**
70 * Insert a widget at the specified index.
71 *
72 * @param index - The index at which to insert the widget.
73 *
74 * @param widget - The widget to insert into to the panel.
75 *
76 * #### Notes
77 * If the widget is already contained in the panel, it will be moved.
78 */
79 insertWidget(index: number, widget: Widget): void {
80 super.insertWidget(index, widget);
81 widget.title.changed.connect(this._onTitleChanged, this);
82 }
83
84 /**
85 * Handle the DOM events for the accordion panel.
86 *
87 * @param event - The DOM event sent to the panel.
88 *
89 * #### Notes
90 * This method implements the DOM `EventListener` interface and is
91 * called in response to events on the panel's DOM node. It should
92 * not be called directly by user code.
93 */
94 handleEvent(event: Event): void {
95 super.handleEvent(event);
96 switch (event.type) {
97 case 'click':
98 this._evtClick(event as MouseEvent);
99 break;
100 case 'keydown':
101 this._eventKeyDown(event as KeyboardEvent);
102 break;
103 }
104 }
105
106 /**
107 * A message handler invoked on a `'before-attach'` message.
108 */
109 protected onBeforeAttach(msg: Message): void {
110 this.node.addEventListener('click', this);
111 this.node.addEventListener('keydown', this);
112 super.onBeforeAttach(msg);
113 }
114
115 /**
116 * A message handler invoked on an `'after-detach'` message.
117 */
118 protected onAfterDetach(msg: Message): void {
119 super.onAfterDetach(msg);
120 this.node.removeEventListener('click', this);
121 this.node.removeEventListener('keydown', this);
122 }
123
124 /**
125 * Handle the `changed` signal of a title object.
126 */
127 private _onTitleChanged(sender: Title<Widget>): void {
128 const index = ArrayExt.findFirstIndex(this.widgets, widget => {
129 return widget.contains(sender.owner);
130 });
131
132 if (index >= 0) {
133 (this.layout as AccordionLayout).updateTitle(index, sender.owner);
134 this.update();
135 }
136 }
137
138 /**
139 * Compute the size of widgets in this panel on the title click event.
140 * On closing, the size of the widget is cached and we will try to expand
141 * the last opened widget.
142 * On opening, we will use the cached size if it is available to restore the
143 * widget.
144 * In both cases, if we can not compute the size of widgets, we will let
145 * `SplitLayout` decide.
146 *
147 * @param index - The index of widget to be opened of closed
148 *
149 * @returns Relative size of widgets in this panel, if this size can
150 * not be computed, return `undefined`
151 */
152 private _computeWidgetSize(index: number): number[] | undefined {
153 const layout = this.layout as AccordionLayout;
154
155 const widget = layout.widgets[index];
156 if (!widget) {
157 return undefined;
158 }
159 const isHidden = widget.isHidden;
160 const widgetSizes = layout.absoluteSizes();
161 const delta = (isHidden ? -1 : 1) * this.spacing;
162 const totalSize = widgetSizes.reduce(
163 (prev: number, curr: number) => prev + curr
164 );
165
166 let newSize = [...widgetSizes];
167
168 if (!isHidden) {
169 // Hide the widget
170 const currentSize = widgetSizes[index];
171
172 this._widgetSizesCache.set(widget, currentSize);
173 newSize[index] = 0;
174
175 const widgetToCollapse = newSize.map(sz => sz > 0).lastIndexOf(true);
176 if (widgetToCollapse === -1) {
177 // All widget are closed, let the `SplitLayout` compute widget sizes.
178 return undefined;
179 }
180
181 newSize[widgetToCollapse] =
182 widgetSizes[widgetToCollapse] + currentSize + delta;
183 } else {
184 // Show the widget
185 const previousSize = this._widgetSizesCache.get(widget);
186 if (!previousSize) {
187 // Previous size is unavailable, let the `SplitLayout` compute widget sizes.
188 return undefined;
189 }
190 newSize[index] += previousSize;
191
192 const widgetToCollapse = newSize
193 .map(sz => sz - previousSize > 0)
194 .lastIndexOf(true);
195 if (widgetToCollapse === -1) {
196 // Can not reduce the size of one widget, reduce all opened widgets
197 // proportionally with its size.
198 newSize.forEach((_, idx) => {
199 if (idx !== index) {
200 newSize[idx] -=
201 (widgetSizes[idx] / totalSize) * (previousSize - delta);
202 }
203 });
204 } else {
205 newSize[widgetToCollapse] -= previousSize - delta;
206 }
207 }
208 return newSize.map(sz => sz / (totalSize + delta));
209 }
210 /**
211 * Handle the `'click'` event for the accordion panel
212 */
213 private _evtClick(event: MouseEvent): void {
214 const target = event.target as HTMLElement | null;
215
216 if (target) {
217 const index = ArrayExt.findFirstIndex(this.titles, title => {
218 return title.contains(target);
219 });
220
221 if (index >= 0) {
222 event.preventDefault();
223 event.stopPropagation();
224 const title = this.titles[index];
225 const widget = (this.layout as AccordionLayout).widgets[index];
226
227 const newSize = this._computeWidgetSize(index);
228 if (newSize) {
229 this.setRelativeSizes(newSize, false);
230 }
231
232 if (widget.isHidden) {
233 title.classList.add('lm-mod-expanded');
234 title.setAttribute('aria-expanded', 'true');
235 widget.show();
236 } else {
237 title.classList.remove('lm-mod-expanded');
238 title.setAttribute('aria-expanded', 'false');
239 widget.hide();
240 }
241 }
242 }
243 }
244
245 /**
246 * Handle the `'keydown'` event for the accordion panel.
247 */
248 private _eventKeyDown(event: KeyboardEvent): void {
249 if (event.defaultPrevented) {
250 return;
251 }
252
253 const target = event.target as HTMLElement | null;
254 let handled = false;
255 if (target) {
256 const index = ArrayExt.findFirstIndex(this.titles, title => {
257 return title.contains(target);
258 });
259
260 if (index >= 0) {
261 const keyCode = event.keyCode.toString();
262
263 // If Space or Enter is pressed on title, emulate click event
264 if (event.key.match(/Space|Enter/) || keyCode.match(/13|32/)) {
265 target.click();
266 handled = true;
267 } else if (
268 this.orientation === 'horizontal'
269 ? event.key.match(/ArrowLeft|ArrowRight/) || keyCode.match(/37|39/)
270 : event.key.match(/ArrowUp|ArrowDown/) || keyCode.match(/38|40/)
271 ) {
272 // If Up or Down (for vertical) / Left or Right (for horizontal) is pressed on title, loop on titles
273 const direction =
274 event.key.match(/ArrowLeft|ArrowUp/) || keyCode.match(/37|38/)
275 ? -1
276 : 1;
277 const length = this.titles.length;
278 const newIndex = (index + length + direction) % length;
279
280 this.titles[newIndex].focus();
281 handled = true;
282 } else if (event.key === 'End' || keyCode === '35') {
283 // If End is pressed on title, focus on the last title
284 this.titles[this.titles.length - 1].focus();
285 handled = true;
286 } else if (event.key === 'Home' || keyCode === '36') {
287 // If Home is pressed on title, focus on the first title
288 this.titles[0].focus();
289 handled = true;
290 }
291 }
292
293 if (handled) {
294 event.preventDefault();
295 }
296 }
297 }
298
299 private _widgetSizesCache: WeakMap<Widget, number> = new WeakMap();
300}
301
302/**
303 * The namespace for the `AccordionPanel` class statics.
304 */
305export namespace AccordionPanel {
306 /**
307 * A type alias for a accordion panel orientation.
308 */
309 export type Orientation = SplitLayout.Orientation;
310
311 /**
312 * A type alias for a accordion panel alignment.
313 */
314 export type Alignment = SplitLayout.Alignment;
315
316 /**
317 * A type alias for a accordion panel renderer.
318 */
319 export type IRenderer = AccordionLayout.IRenderer;
320
321 /**
322 * An options object for initializing a accordion panel.
323 */
324 export interface IOptions extends Partial<AccordionLayout.IOptions> {
325 /**
326 * The accordion layout to use for the accordion panel.
327 *
328 * If this is provided, the other options are ignored.
329 *
330 * The default is a new `AccordionLayout`.
331 */
332 layout?: AccordionLayout;
333 }
334
335 /**
336 * The default implementation of `IRenderer`.
337 */
338 export class Renderer extends SplitPanel.Renderer implements IRenderer {
339 /**
340 * A selector which matches any title node in the accordion.
341 */
342 readonly titleClassName = 'lm-AccordionPanel-title';
343
344 /**
345 * Render the collapse indicator for a section title.
346 *
347 * @param data - The data to use for rendering the section title.
348 *
349 * @returns A element representing the collapse indicator.
350 */
351 createCollapseIcon(data: Title<Widget>): HTMLElement {
352 return document.createElement('span');
353 }
354
355 /**
356 * Render the element for a section title.
357 *
358 * @param data - The data to use for rendering the section title.
359 *
360 * @returns A element representing the section title.
361 */
362 createSectionTitle(data: Title<Widget>): HTMLElement {
363 const handle = document.createElement('h3');
364 handle.setAttribute('role', 'button');
365 handle.setAttribute('tabindex', '0');
366 handle.id = this.createTitleKey(data);
367 handle.className = this.titleClassName;
368 handle.title = data.caption;
369 for (const aData in data.dataset) {
370 handle.dataset[aData] = data.dataset[aData];
371 }
372
373 const collapser = handle.appendChild(this.createCollapseIcon(data));
374 collapser.className = 'lm-AccordionPanel-titleCollapser';
375
376 const label = handle.appendChild(document.createElement('span'));
377 label.className = 'lm-AccordionPanel-titleLabel';
378 label.textContent = data.label;
379
380 return handle;
381 }
382
383 /**
384 * Create a unique render key for the title.
385 *
386 * @param data - The data to use for the title.
387 *
388 * @returns The unique render key for the title.
389 *
390 * #### Notes
391 * This method caches the key against the section title the first time
392 * the key is generated.
393 */
394 createTitleKey(data: Title<Widget>): string {
395 let key = this._titleKeys.get(data);
396 if (key === undefined) {
397 key = `title-key-${this._titleID++}`;
398 this._titleKeys.set(data, key);
399 }
400 return key;
401 }
402
403 private _titleID = 0;
404 private _titleKeys = new WeakMap<Title<Widget>, string>();
405 }
406
407 /**
408 * The default `Renderer` instance.
409 */
410 export const defaultRenderer = new Renderer();
411}
412
413namespace Private {
414 /**
415 * Create an accordion layout for the given panel options.
416 *
417 * @param options Panel options
418 * @returns Panel layout
419 */
420 export function createLayout(
421 options: AccordionPanel.IOptions
422 ): AccordionLayout {
423 return (
424 options.layout ||
425 new AccordionLayout({
426 renderer: options.renderer || AccordionPanel.defaultRenderer,
427 orientation: options.orientation,
428 alignment: options.alignment,
429 spacing: options.spacing,
430 titleSpace: options.titleSpace
431 })
432 );
433 }
434}