UNPKG

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