UNPKG

28.9 kBTypeScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { ITranslator, nullTranslator } from '@jupyterlab/translation';
5import { find, map, some } from '@lumino/algorithm';
6import { CommandRegistry } from '@lumino/commands';
7import { ReadonlyJSONObject } from '@lumino/coreutils';
8import { Message, MessageLoop } from '@lumino/messaging';
9import { AttachedProperty } from '@lumino/properties';
10import { Layout, PanelLayout, Widget } from '@lumino/widgets';
11import { Throttler } from '@lumino/polling';
12import * as React from 'react';
13import { Button } from './button';
14import { ellipsesIcon, LabIcon } from '../icon';
15import { classes } from '../utils';
16import { ReactWidget, UseSignal } from './vdom';
17
18/**
19 * The class name added to toolbars.
20 */
21const TOOLBAR_CLASS = 'jp-Toolbar';
22
23/**
24 * The class name added to toolbar items.
25 */
26const TOOLBAR_ITEM_CLASS = 'jp-Toolbar-item';
27
28/**
29 * Toolbar pop-up opener button name
30 */
31const TOOLBAR_OPENER_NAME = 'toolbar-popup-opener';
32
33/**
34 * The class name added to toolbar spacer.
35 */
36const TOOLBAR_SPACER_CLASS = 'jp-Toolbar-spacer';
37
38/**
39 * A layout for toolbars.
40 *
41 * #### Notes
42 * This layout automatically collapses its height if there are no visible
43 * toolbar widgets, and expands to the standard toolbar height if there are
44 * visible toolbar widgets.
45 */
46class ToolbarLayout extends PanelLayout {
47 /**
48 * A message handler invoked on a `'fit-request'` message.
49 *
50 * If any child widget is visible, expand the toolbar height to the normal
51 * toolbar height.
52 */
53 protected onFitRequest(msg: Message): void {
54 super.onFitRequest(msg);
55 if (this.parent!.isAttached) {
56 // If there are any widgets not explicitly hidden, expand the toolbar to
57 // accommodate them.
58 if (some(this.widgets, w => !w.isHidden)) {
59 this.parent!.node.style.minHeight = 'var(--jp-private-toolbar-height)';
60 this.parent!.removeClass('jp-Toolbar-micro');
61 } else {
62 this.parent!.node.style.minHeight = '';
63 this.parent!.addClass('jp-Toolbar-micro');
64 }
65 }
66
67 // Set the dirty flag to ensure only a single update occurs.
68 this._dirty = true;
69
70 // Notify the ancestor that it should fit immediately. This may
71 // cause a resize of the parent, fulfilling the required update.
72 if (this.parent!.parent) {
73 MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
74 }
75
76 // If the dirty flag is still set, the parent was not resized.
77 // Trigger the required update on the parent widget immediately.
78 if (this._dirty) {
79 MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
80 }
81 }
82
83 /**
84 * A message handler invoked on an `'update-request'` message.
85 */
86 protected onUpdateRequest(msg: Message): void {
87 super.onUpdateRequest(msg);
88 if (this.parent!.isVisible) {
89 this._dirty = false;
90 }
91 }
92
93 /**
94 * A message handler invoked on a `'child-shown'` message.
95 */
96 protected onChildShown(msg: Widget.ChildMessage): void {
97 super.onChildShown(msg);
98
99 // Post a fit request for the parent widget.
100 this.parent!.fit();
101 }
102
103 /**
104 * A message handler invoked on a `'child-hidden'` message.
105 */
106 protected onChildHidden(msg: Widget.ChildMessage): void {
107 super.onChildHidden(msg);
108
109 // Post a fit request for the parent widget.
110 this.parent!.fit();
111 }
112
113 /**
114 * A message handler invoked on a `'before-attach'` message.
115 */
116 protected onBeforeAttach(msg: Message): void {
117 super.onBeforeAttach(msg);
118
119 // Post a fit request for the parent widget.
120 this.parent!.fit();
121 }
122
123 /**
124 * Attach a widget to the parent's DOM node.
125 *
126 * @param index - The current index of the widget in the layout.
127 *
128 * @param widget - The widget to attach to the parent.
129 *
130 * #### Notes
131 * This is a reimplementation of the superclass method.
132 */
133 protected attachWidget(index: number, widget: Widget): void {
134 super.attachWidget(index, widget);
135
136 // Post a fit request for the parent widget.
137 this.parent!.fit();
138 }
139
140 /**
141 * Detach a widget from the parent's DOM node.
142 *
143 * @param index - The previous index of the widget in the layout.
144 *
145 * @param widget - The widget to detach from the parent.
146 *
147 * #### Notes
148 * This is a reimplementation of the superclass method.
149 */
150 protected detachWidget(index: number, widget: Widget): void {
151 super.detachWidget(index, widget);
152
153 // Post a fit request for the parent widget.
154 this.parent!.fit();
155 }
156
157 private _dirty = false;
158}
159
160/**
161 * A class which provides a toolbar widget.
162 */
163export class Toolbar<T extends Widget = Widget> extends Widget {
164 /**
165 * Construct a new toolbar widget.
166 */
167 constructor(options: Toolbar.IOptions = {}) {
168 super();
169 this.addClass(TOOLBAR_CLASS);
170 this.layout = options.layout ?? new ToolbarLayout();
171 }
172
173 /**
174 * Get an iterator over the ordered toolbar item names.
175 *
176 * @returns An iterator over the toolbar item names.
177 */
178 names(): IterableIterator<string> {
179 const layout = this.layout as ToolbarLayout;
180 return map(layout.widgets, widget => {
181 return Private.nameProperty.get(widget);
182 });
183 }
184
185 /**
186 * Add an item to the end of the toolbar.
187 *
188 * @param name - The name of the widget to add to the toolbar.
189 *
190 * @param widget - The widget to add to the toolbar.
191 *
192 * @param index - The optional name of the item to insert after.
193 *
194 * @returns Whether the item was added to toolbar. Returns false if
195 * an item of the same name is already in the toolbar.
196 *
197 * #### Notes
198 * The item can be removed from the toolbar by setting its parent to `null`.
199 */
200 addItem(name: string, widget: T): boolean {
201 const layout = this.layout as ToolbarLayout;
202 return this.insertItem(layout.widgets.length, name, widget);
203 }
204
205 /**
206 * Insert an item into the toolbar at the specified index.
207 *
208 * @param index - The index at which to insert the item.
209 *
210 * @param name - The name of the item.
211 *
212 * @param widget - The widget to add.
213 *
214 * @returns Whether the item was added to the toolbar. Returns false if
215 * an item of the same name is already in the toolbar.
216 *
217 * #### Notes
218 * The index will be clamped to the bounds of the items.
219 * The item can be removed from the toolbar by setting its parent to `null`.
220 */
221 insertItem(index: number, name: string, widget: T): boolean {
222 const existing = find(this.names(), value => value === name);
223 if (existing) {
224 return false;
225 }
226 widget.addClass(TOOLBAR_ITEM_CLASS);
227 const layout = this.layout as ToolbarLayout;
228
229 const j = Math.max(0, Math.min(index, layout.widgets.length));
230 layout.insertWidget(j, widget);
231
232 Private.nameProperty.set(widget, name);
233 return true;
234 }
235
236 /**
237 * Insert an item into the toolbar at the after a target item.
238 *
239 * @param at - The target item to insert after.
240 *
241 * @param name - The name of the item.
242 *
243 * @param widget - The widget to add.
244 *
245 * @returns Whether the item was added to the toolbar. Returns false if
246 * an item of the same name is already in the toolbar.
247 *
248 * #### Notes
249 * The index will be clamped to the bounds of the items.
250 * The item can be removed from the toolbar by setting its parent to `null`.
251 */
252 insertAfter(at: string, name: string, widget: T): boolean {
253 return this._insertRelative(at, 1, name, widget);
254 }
255
256 /**
257 * Insert an item into the toolbar at the before a target item.
258 *
259 * @param at - The target item to insert before.
260 *
261 * @param name - The name of the item.
262 *
263 * @param widget - The widget to add.
264 *
265 * @returns Whether the item was added to the toolbar. Returns false if
266 * an item of the same name is already in the toolbar.
267 *
268 * #### Notes
269 * The index will be clamped to the bounds of the items.
270 * The item can be removed from the toolbar by setting its parent to `null`.
271 */
272 insertBefore(at: string, name: string, widget: T): boolean {
273 return this._insertRelative(at, 0, name, widget);
274 }
275
276 private _insertRelative(
277 at: string,
278 offset: number,
279 name: string,
280 widget: T
281 ): boolean {
282 const nameWithIndex = map(this.names(), (name, i) => {
283 return { name: name, index: i };
284 });
285 const target = find(nameWithIndex, x => x.name === at);
286 if (target) {
287 return this.insertItem(target.index + offset, name, widget);
288 }
289 return false;
290 }
291
292 /**
293 * Handle the DOM events for the widget.
294 *
295 * @param event - The DOM event sent to the widget.
296 *
297 * #### Notes
298 * This method implements the DOM `EventListener` interface and is
299 * called in response to events on the dock panel's node. It should
300 * not be called directly by user code.
301 */
302 handleEvent(event: Event): void {
303 switch (event.type) {
304 case 'click':
305 this.handleClick(event);
306 break;
307 default:
308 break;
309 }
310 }
311
312 /**
313 * Handle a DOM click event.
314 */
315 protected handleClick(event: Event): void {
316 // Stop propagating the click outside the toolbar
317 event.stopPropagation();
318
319 // Clicking a label focuses the corresponding control
320 // that is linked with `for` attribute, so let it be.
321 if (event.target instanceof HTMLLabelElement) {
322 const forId = event.target.getAttribute('for');
323 if (forId && this.node.querySelector(`#${forId}`)) {
324 return;
325 }
326 }
327
328 // If this click already focused a control, let it be.
329 if (this.node.contains(document.activeElement)) {
330 return;
331 }
332
333 // Otherwise, activate the parent widget, which may take focus if desired.
334 if (this.parent) {
335 this.parent.activate();
336 }
337 }
338
339 /**
340 * Handle `after-attach` messages for the widget.
341 */
342 protected onAfterAttach(msg: Message): void {
343 this.node.addEventListener('click', this);
344 }
345
346 /**
347 * Handle `before-detach` messages for the widget.
348 */
349 protected onBeforeDetach(msg: Message): void {
350 this.node.removeEventListener('click', this);
351 }
352}
353
354/**
355 * A class which provides a toolbar widget.
356 */
357export class ReactiveToolbar extends Toolbar<Widget> {
358 /**
359 * Construct a new toolbar widget.
360 */
361 constructor() {
362 super();
363 this.insertItem(0, TOOLBAR_OPENER_NAME, this.popupOpener);
364 this.popupOpener.hide();
365 this._resizer = new Throttler(this._onResize.bind(this), 500);
366 }
367
368 /**
369 * Dispose of the widget and its descendant widgets.
370 */
371 dispose(): void {
372 if (this.isDisposed) {
373 return;
374 }
375
376 if (this._resizer) {
377 this._resizer.dispose();
378 }
379
380 super.dispose();
381 }
382
383 /**
384 * Insert an item into the toolbar at the after a target item.
385 *
386 * @param at - The target item to insert after.
387 *
388 * @param name - The name of the item.
389 *
390 * @param widget - The widget to add.
391 *
392 * @returns Whether the item was added to the toolbar. Returns false if
393 * an item of the same name is already in the toolbar or if the target
394 * is the toolbar pop-up opener.
395 *
396 * #### Notes
397 * The index will be clamped to the bounds of the items.
398 * The item can be removed from the toolbar by setting its parent to `null`.
399 */
400 insertAfter(at: string, name: string, widget: Widget): boolean {
401 if (at === TOOLBAR_OPENER_NAME) {
402 return false;
403 }
404 return super.insertAfter(at, name, widget);
405 }
406
407 /**
408 * Insert an item into the toolbar at the specified index.
409 *
410 * @param index - The index at which to insert the item.
411 *
412 * @param name - The name of the item.
413 *
414 * @param widget - The widget to add.
415 *
416 * @returns Whether the item was added to the toolbar. Returns false if
417 * an item of the same name is already in the toolbar.
418 *
419 * #### Notes
420 * The index will be clamped to the bounds of the items.
421 * The item can be removed from the toolbar by setting its parent to `null`.
422 */
423 insertItem(index: number, name: string, widget: Widget): boolean {
424 if (widget instanceof ToolbarPopupOpener) {
425 return super.insertItem(index, name, widget);
426 } else {
427 const j = Math.max(
428 0,
429 Math.min(index, (this.layout as ToolbarLayout).widgets.length - 1)
430 );
431 return super.insertItem(j, name, widget);
432 }
433 }
434
435 /**
436 * A message handler invoked on a `'before-hide'` message.
437 *
438 * It will hide the pop-up panel
439 */
440 onBeforeHide(msg: Message): void {
441 this.popupOpener.hidePopup();
442 super.onBeforeHide(msg);
443 }
444
445 protected onResize(msg: Widget.ResizeMessage): void {
446 super.onResize(msg);
447 if (msg.width > 0 && this._resizer) {
448 void this._resizer.invoke();
449 }
450 }
451
452 private _onResize() {
453 if (this.parent && this.parent.isAttached) {
454 const toolbarWidth = this.node.clientWidth;
455 const opener = this.popupOpener;
456 const openerWidth = 30;
457 const toolbarPadding = 2;
458 const layout = this.layout as ToolbarLayout;
459
460 let width = opener.isHidden
461 ? toolbarPadding
462 : toolbarPadding + openerWidth;
463 let index = 0;
464 const widgetsToRemove = [];
465 const toIndex = layout.widgets.length - 1;
466
467 while (index < toIndex) {
468 const widget = layout.widgets[index];
469 this._saveWidgetWidth(widget);
470 width += this._getWidgetWidth(widget);
471 if (
472 widgetsToRemove.length === 0 &&
473 opener.isHidden &&
474 width + openerWidth > toolbarWidth
475 ) {
476 width += openerWidth;
477 }
478 if (width > toolbarWidth) {
479 widgetsToRemove.push(widget);
480 }
481 index++;
482 }
483
484 while (widgetsToRemove.length > 0) {
485 const widget = widgetsToRemove.pop() as Widget;
486 width -= this._getWidgetWidth(widget);
487 opener.addWidget(widget);
488 }
489
490 if (opener.widgetCount() > 0) {
491 const widgetsToAdd = [];
492 let index = 0;
493 let widget = opener.widgetAt(index);
494 const widgetCount = opener.widgetCount();
495
496 width += this._getWidgetWidth(widget);
497
498 if (widgetCount === 1 && width - openerWidth <= toolbarWidth) {
499 width -= openerWidth;
500 }
501
502 while (width < toolbarWidth && index < widgetCount) {
503 widgetsToAdd.push(widget);
504 index++;
505 widget = opener.widgetAt(index);
506 if (widget) {
507 width += this._getWidgetWidth(widget);
508 } else {
509 break;
510 }
511 }
512
513 while (widgetsToAdd.length > 0) {
514 const widget = widgetsToAdd.shift()!;
515 this.addItem(Private.nameProperty.get(widget), widget);
516 }
517 }
518
519 if (opener.widgetCount() > 0) {
520 opener.updatePopup();
521 opener.show();
522 } else {
523 opener.hide();
524 }
525 }
526 }
527
528 private _saveWidgetWidth(widget: Widget) {
529 const widgetName = Private.nameProperty.get(widget);
530 this._widgetWidths![widgetName] = widget.hasClass(TOOLBAR_SPACER_CLASS)
531 ? 2
532 : widget.node.clientWidth;
533 }
534
535 private _getWidgetWidth(widget: Widget): number {
536 const widgetName = Private.nameProperty.get(widget);
537 return this._widgetWidths![widgetName];
538 }
539
540 protected readonly popupOpener: ToolbarPopupOpener = new ToolbarPopupOpener();
541 private readonly _widgetWidths: { [key: string]: number } = {};
542 private readonly _resizer: Throttler;
543}
544
545/**
546 * The namespace for Toolbar class statics.
547 */
548export namespace Toolbar {
549 /**
550 * The options used to create a toolbar.
551 */
552 export interface IOptions {
553 /**
554 * Toolbar widget layout.
555 */
556 layout?: Layout;
557 }
558
559 /**
560 * Widget with associated toolbar
561 */
562 export interface IWidgetToolbar extends Widget {
563 /**
564 * Toolbar of actions on the widget
565 */
566 toolbar?: Toolbar;
567 }
568
569 /**
570 * Create a toolbar spacer item.
571 *
572 * #### Notes
573 * It is a flex spacer that separates the left toolbar items
574 * from the right toolbar items.
575 */
576 export function createSpacerItem(): Widget {
577 return new Private.Spacer();
578 }
579}
580
581/**
582 * Namespace for ToolbarButtonComponent.
583 */
584export namespace ToolbarButtonComponent {
585 /**
586 * Interface for ToolbarButtonComponent props.
587 */
588 export interface IProps {
589 className?: string;
590 /**
591 * Data set of the button
592 */
593 dataset?: DOMStringMap;
594 label?: string;
595 icon?: LabIcon.IMaybeResolvable;
596 iconClass?: string;
597 iconLabel?: string;
598 tooltip?: string;
599 onClick?: () => void;
600 enabled?: boolean;
601 pressed?: boolean;
602 pressedIcon?: LabIcon.IMaybeResolvable;
603 pressedTooltip?: string;
604 disabledTooltip?: string;
605
606 /**
607 * Trigger the button on the actual onClick event rather than onMouseDown.
608 *
609 * See note in ToolbarButtonComponent below as to why the default is to
610 * trigger on onMouseDown.
611 */
612 actualOnClick?: boolean;
613
614 /**
615 * The application language translator.
616 */
617 translator?: ITranslator;
618 }
619}
620
621/**
622 * React component for a toolbar button.
623 *
624 * @param props - The props for ToolbarButtonComponent.
625 */
626export function ToolbarButtonComponent(
627 props: ToolbarButtonComponent.IProps
628): JSX.Element {
629 // In some browsers, a button click event moves the focus from the main
630 // content to the button (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus).
631 // We avoid a click event by calling preventDefault in mousedown, and
632 // we bind the button action to `mousedown`.
633 const handleMouseDown = (event: React.MouseEvent) => {
634 // Fire action only when left button is pressed.
635 if (event.button === 0) {
636 event.preventDefault();
637 props.onClick?.();
638 }
639 };
640
641 const handleKeyDown = (event: React.KeyboardEvent) => {
642 const { key } = event;
643 if (key === 'Enter' || key === ' ') {
644 props.onClick?.();
645 }
646 };
647
648 const handleClick = (event: React.MouseEvent) => {
649 if (event.button === 0) {
650 props.onClick?.();
651 }
652 };
653
654 const getTooltip = () => {
655 if (props.enabled === false && props.disabledTooltip) {
656 return props.disabledTooltip;
657 } else if (props.pressed && props.pressedTooltip) {
658 return props.pressedTooltip;
659 } else {
660 return props.tooltip || props.iconLabel;
661 }
662 };
663
664 return (
665 <Button
666 className={
667 props.className
668 ? props.className + ' jp-ToolbarButtonComponent'
669 : 'jp-ToolbarButtonComponent'
670 }
671 aria-pressed={props.pressed}
672 aria-disabled={props.enabled === false}
673 {...props.dataset}
674 disabled={props.enabled === false}
675 onClick={props.actualOnClick ?? false ? handleClick : undefined}
676 onMouseDown={
677 !(props.actualOnClick ?? false) ? handleMouseDown : undefined
678 }
679 onKeyDown={handleKeyDown}
680 title={getTooltip()}
681 minimal
682 >
683 {(props.icon || props.iconClass) && (
684 <LabIcon.resolveReact
685 icon={props.pressed ? props.pressedIcon : props.icon}
686 iconClass={
687 // add some extra classes for proper support of icons-as-css-background
688 classes(props.iconClass, 'jp-Icon')
689 }
690 className="jp-ToolbarButtonComponent-icon"
691 tag="span"
692 stylesheet="toolbarButton"
693 />
694 )}
695 {props.label && (
696 <span className="jp-ToolbarButtonComponent-label">{props.label}</span>
697 )}
698 </Button>
699 );
700}
701
702/**
703 * Adds the toolbar button class to the toolbar widget.
704 * @param w Toolbar button widget.
705 */
706export function addToolbarButtonClass<T extends Widget = Widget>(w: T): T {
707 w.addClass('jp-ToolbarButton');
708 return w;
709}
710
711/**
712 * Lumino Widget version of static ToolbarButtonComponent.
713 */
714export class ToolbarButton extends ReactWidget {
715 /**
716 * Creates a toolbar button
717 * @param props props for underlying `ToolbarButton` component
718 */
719 constructor(private props: ToolbarButtonComponent.IProps = {}) {
720 super();
721 addToolbarButtonClass(this);
722 this._enabled = props.enabled ?? true;
723 this._pressed = this._enabled! && (props.pressed ?? false);
724 this._onClick = props.onClick!;
725 }
726
727 /**
728 * Sets the pressed state for the button
729 * @param value true if button is pressed, false otherwise
730 */
731 set pressed(value: boolean) {
732 if (this.enabled && value !== this._pressed) {
733 this._pressed = value;
734 this.update();
735 }
736 }
737
738 /**
739 * Returns true if button is pressed, false otherwise
740 */
741 get pressed(): boolean {
742 return this._pressed!;
743 }
744
745 /**
746 * Sets the enabled state for the button
747 * @param value true to enable the button, false otherwise
748 */
749 set enabled(value: boolean) {
750 if (value != this._enabled) {
751 this._enabled = value;
752 if (!this._enabled) {
753 this._pressed = false;
754 }
755 this.update();
756 }
757 }
758
759 /**
760 * Returns true if button is enabled, false otherwise
761 */
762 get enabled(): boolean {
763 return this._enabled;
764 }
765
766 /**
767 * Sets the click handler for the button
768 * @param value click handler
769 */
770 set onClick(value: () => void) {
771 if (value !== this._onClick) {
772 this._onClick = value;
773 this.update();
774 }
775 }
776
777 /**
778 * Returns the click handler for the button
779 */
780 get onClick(): () => void {
781 return this._onClick!;
782 }
783
784 render(): JSX.Element {
785 return (
786 <ToolbarButtonComponent
787 {...this.props}
788 pressed={this.pressed}
789 enabled={this.enabled}
790 onClick={this.onClick}
791 />
792 );
793 }
794
795 private _pressed: boolean;
796 private _enabled: boolean;
797 private _onClick: () => void;
798}
799
800/**
801 * Namespace for CommandToolbarButtonComponent.
802 */
803export namespace CommandToolbarButtonComponent {
804 /**
805 * Interface for CommandToolbarButtonComponent props.
806 */
807 export interface IProps {
808 /**
809 * Application commands registry
810 */
811 commands: CommandRegistry;
812 /**
813 * Command unique id
814 */
815 id: string;
816 /**
817 * Command arguments
818 */
819 args?: ReadonlyJSONObject;
820 /**
821 * Overrides command icon
822 */
823 icon?: LabIcon;
824 /**
825 * Overrides command label
826 */
827 label?: string;
828 }
829}
830
831/**
832 * React component for a toolbar button that wraps a command.
833 *
834 * This wraps the ToolbarButtonComponent and watches the command registry
835 * for changes to the command.
836 */
837export function CommandToolbarButtonComponent(
838 props: CommandToolbarButtonComponent.IProps
839): JSX.Element {
840 return (
841 <UseSignal
842 signal={props.commands.commandChanged}
843 shouldUpdate={(sender, args) =>
844 (args.id === props.id && args.type === 'changed') ||
845 args.type === 'many-changed'
846 }
847 >
848 {() => <ToolbarButtonComponent {...Private.propsFromCommand(props)} />}
849 </UseSignal>
850 );
851}
852
853/*
854 * Adds the command toolbar button class to the command toolbar widget.
855 * @param w Command toolbar button widget.
856 */
857export function addCommandToolbarButtonClass(w: Widget): Widget {
858 w.addClass('jp-CommandToolbarButton');
859 return w;
860}
861
862/**
863 * Phosphor Widget version of CommandToolbarButtonComponent.
864 */
865export class CommandToolbarButton extends ReactWidget {
866 /**
867 * Creates a command toolbar button
868 * @param props props for underlying `CommandToolbarButtonComponent` component
869 */
870 constructor(private props: CommandToolbarButtonComponent.IProps) {
871 super();
872 addCommandToolbarButtonClass(this);
873 }
874 render(): JSX.Element {
875 return <CommandToolbarButtonComponent {...this.props} />;
876 }
877}
878
879/**
880 * A class which provides a toolbar popup
881 * used to store widgets that don't fit
882 * in the toolbar when it is resized
883 */
884class ToolbarPopup extends Widget {
885 width: number = 0;
886
887 /**
888 * Construct a new ToolbarPopup
889 */
890 constructor() {
891 super();
892 this.addClass('jp-Toolbar-responsive-popup');
893 this.layout = new PanelLayout();
894 Widget.attach(this, document.body);
895 this.hide();
896 }
897
898 /**
899 * Updates the width of the popup, this
900 * should match with the toolbar width
901 *
902 * @param width - The width to resize to
903 * @protected
904 */
905 updateWidth(width: number) {
906 if (width > 0) {
907 this.width = width;
908 this.node.style.width = `${width}px`;
909 }
910 }
911
912 /**
913 * Aligns the popup to left bottom of widget
914 *
915 * @param widget the widget to align to
916 * @private
917 */
918 alignTo(widget: Widget) {
919 const {
920 height: widgetHeight,
921 width: widgetWidth,
922 x: widgetX,
923 y: widgetY
924 } = widget.node.getBoundingClientRect();
925 const width = this.width;
926 this.node.style.left = `${widgetX + widgetWidth - width + 1}px`;
927 this.node.style.top = `${widgetY + widgetHeight + 1}px`;
928 }
929
930 /**
931 * Inserts the widget at specified index
932 * @param index the index
933 * @param widget widget to add
934 */
935 insertWidget(index: number, widget: Widget) {
936 (this.layout as PanelLayout).insertWidget(0, widget);
937 }
938
939 /**
940 * Total number of widgets in the popup
941 */
942 widgetCount() {
943 return (this.layout as PanelLayout).widgets.length;
944 }
945
946 /**
947 * Returns the widget at index
948 * @param index the index
949 */
950 widgetAt(index: number) {
951 return (this.layout as PanelLayout).widgets[index];
952 }
953}
954
955/**
956 * A class that provides a ToolbarPopupOpener,
957 * which is a button added to toolbar when
958 * the toolbar items overflow toolbar width
959 */
960class ToolbarPopupOpener extends ToolbarButton {
961 /**
962 * Create a new popup opener
963 */
964 constructor(props: ToolbarButtonComponent.IProps = {}) {
965 const trans = (props.translator || nullTranslator).load('jupyterlab');
966 super({
967 icon: ellipsesIcon,
968 onClick: () => {
969 this.handleClick();
970 },
971 tooltip: trans.__('More commands')
972 });
973 this.addClass('jp-Toolbar-responsive-opener');
974
975 this.popup = new ToolbarPopup();
976 }
977
978 /**
979 * Add widget to the popup, prepends widgets
980 * @param widget the widget to add
981 */
982 addWidget(widget: Widget) {
983 this.popup.insertWidget(0, widget);
984 }
985
986 /**
987 * Dispose of the widget and its descendant widgets.
988 *
989 * #### Notes
990 * It is unsafe to use the widget after it has been disposed.
991 *
992 * All calls made to this method after the first are a no-op.
993 */
994 dispose(): void {
995 if (this.isDisposed) {
996 return;
997 }
998 this.popup.dispose();
999 super.dispose();
1000 }
1001
1002 /**
1003 * Hides the opener and the popup
1004 */
1005 hide(): void {
1006 super.hide();
1007 this.hidePopup();
1008 }
1009
1010 /**
1011 * Hides the popup
1012 */
1013 hidePopup(): void {
1014 this.popup.hide();
1015 }
1016
1017 /**
1018 * Updates width and position of the popup
1019 * to align with the toolbar
1020 */
1021 updatePopup(): void {
1022 this.popup.updateWidth(this.parent!.node.clientWidth);
1023 this.popup.alignTo(this.parent!);
1024 }
1025
1026 /**
1027 * Returns widget at index in the popup
1028 * @param index
1029 */
1030 widgetAt(index: number) {
1031 return this.popup.widgetAt(index);
1032 }
1033
1034 /**
1035 * Returns total number of widgets in the popup
1036 *
1037 * @returns Number of widgets
1038 */
1039 widgetCount(): number {
1040 return this.popup.widgetCount();
1041 }
1042
1043 protected handleClick() {
1044 this.updatePopup();
1045 this.popup.setHidden(!this.popup.isHidden);
1046 }
1047
1048 protected popup: ToolbarPopup;
1049}
1050
1051/**
1052 * A namespace for private data.
1053 */
1054namespace Private {
1055 export function propsFromCommand(
1056 options: CommandToolbarButtonComponent.IProps
1057 ): ToolbarButtonComponent.IProps {
1058 const { commands, id, args } = options;
1059
1060 const iconClass = commands.iconClass(id, args);
1061 const iconLabel = commands.iconLabel(id, args);
1062 const icon = options.icon ?? commands.icon(id, args);
1063
1064 const label = commands.label(id, args);
1065 let className = commands.className(id, args);
1066 // Add the boolean state classes.
1067 if (commands.isToggled(id, args)) {
1068 className += ' lm-mod-toggled';
1069 }
1070 if (!commands.isVisible(id, args)) {
1071 className += ' lm-mod-hidden';
1072 }
1073
1074 let tooltip =
1075 commands.caption(id, args) || options.label || label || iconLabel;
1076 // Shows hot keys in tooltips
1077 const binding = commands.keyBindings.find(b => b.command === id);
1078 if (binding) {
1079 const ks = binding.keys.map(CommandRegistry.formatKeystroke).join(', ');
1080 tooltip = `${tooltip} (${ks})`;
1081 }
1082 const onClick = () => {
1083 void commands.execute(id, args);
1084 };
1085 const enabled = commands.isEnabled(id, args);
1086
1087 return {
1088 className,
1089 dataset: { 'data-command': options.id },
1090 icon,
1091 iconClass,
1092 tooltip,
1093 onClick,
1094 enabled,
1095 label: options.label ?? label
1096 };
1097 }
1098
1099 /**
1100 * An attached property for the name of a toolbar item.
1101 */
1102 export const nameProperty = new AttachedProperty<Widget, string>({
1103 name: 'name',
1104 create: () => ''
1105 });
1106
1107 /**
1108 * A spacer widget.
1109 */
1110 export class Spacer extends Widget {
1111 /**
1112 * Construct a new spacer widget.
1113 */
1114 constructor() {
1115 super();
1116 this.addClass(TOOLBAR_SPACER_CLASS);
1117 }
1118 }
1119}