@xh/hoist 79.0.0-SNAPSHOT.1766020485210 → 79.0.0-SNAPSHOT.1766097863558

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/admin/AppComponent.ts +9 -1
  3. package/admin/AppModel.ts +0 -4
  4. package/admin/tabs/cluster/instances/InstancesTab.ts +1 -1
  5. package/admin/tabs/cluster/instances/InstancesTabModel.ts +0 -1
  6. package/admin/tabs/userData/roles/details/RoleDetailsModel.ts +0 -1
  7. package/build/types/cmp/tab/TabContainer.d.ts +19 -4
  8. package/build/types/cmp/tab/TabContainerModel.d.ts +18 -19
  9. package/build/types/cmp/tab/Types.d.ts +61 -0
  10. package/build/types/cmp/tab/index.d.ts +1 -1
  11. package/build/types/core/elem.d.ts +3 -3
  12. package/build/types/data/RecordAction.d.ts +4 -1
  13. package/build/types/desktop/cmp/dash/canvas/DashCanvas.d.ts +3 -2
  14. package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +45 -3
  15. package/build/types/desktop/cmp/panel/Panel.d.ts +2 -2
  16. package/build/types/desktop/cmp/rest/RestGrid.d.ts +3 -3
  17. package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
  18. package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcher.d.ts +7 -0
  19. package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.d.ts +30 -0
  20. package/build/types/desktop/cmp/tab/dynamic/scroller/Scroller.d.ts +19 -0
  21. package/build/types/desktop/cmp/tab/dynamic/scroller/ScrollerModel.d.ts +23 -0
  22. package/build/types/desktop/cmp/tab/impl/Tab.d.ts +7 -2
  23. package/build/types/desktop/cmp/tab/impl/TabContainer.d.ts +1 -1
  24. package/build/types/desktop/cmp/tab/impl/TabContextMenuItems.d.ts +4 -0
  25. package/build/types/desktop/cmp/tab/index.d.ts +1 -0
  26. package/build/types/dynamics/desktop.d.ts +1 -0
  27. package/build/types/mobile/cmp/panel/Panel.d.ts +2 -2
  28. package/build/types/mobile/cmp/tab/impl/TabContainer.d.ts +1 -1
  29. package/cmp/tab/TabContainer.ts +19 -4
  30. package/cmp/tab/TabContainerModel.ts +113 -54
  31. package/cmp/tab/TabModel.ts +1 -2
  32. package/cmp/tab/Types.ts +80 -0
  33. package/cmp/tab/index.ts +1 -1
  34. package/core/elem.ts +5 -5
  35. package/data/RecordAction.ts +4 -1
  36. package/desktop/appcontainer/AppContainer.ts +3 -2
  37. package/desktop/cmp/dash/canvas/DashCanvas.ts +57 -35
  38. package/desktop/cmp/dash/canvas/DashCanvasModel.ts +135 -21
  39. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilter.ts +1 -1
  40. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +0 -1
  41. package/desktop/cmp/panel/Panel.ts +2 -2
  42. package/desktop/cmp/rest/RestGrid.ts +4 -5
  43. package/desktop/cmp/tab/TabSwitcher.ts +18 -3
  44. package/desktop/cmp/tab/Tabs.scss +1 -0
  45. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss +53 -0
  46. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +237 -0
  47. package/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts +167 -0
  48. package/desktop/cmp/tab/dynamic/scroller/Scroller.ts +69 -0
  49. package/desktop/cmp/tab/dynamic/scroller/ScrollerModel.ts +92 -0
  50. package/desktop/cmp/tab/impl/Tab.ts +30 -6
  51. package/desktop/cmp/tab/impl/TabContainer.ts +34 -9
  52. package/desktop/cmp/tab/impl/TabContextMenuItems.ts +21 -0
  53. package/desktop/cmp/tab/index.ts +1 -0
  54. package/dynamics/desktop.ts +2 -0
  55. package/mobile/cmp/panel/Panel.ts +2 -2
  56. package/mobile/cmp/tab/impl/TabContainer.ts +16 -9
  57. package/package.json +2 -3
  58. package/tsconfig.tsbuildinfo +1 -1
  59. package/build/types/cmp/tab/TabSwitcherProps.d.ts +0 -16
  60. package/cmp/tab/TabSwitcherProps.ts +0 -28
@@ -4,6 +4,9 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {wait} from '@xh/hoist/promise';
8
+ import {DragEvent} from 'react';
9
+ import type {LayoutItem} from 'react-grid-layout';
7
10
  import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core';
8
11
  import {required} from '@xh/hoist/data';
9
12
  import {DashCanvasViewModel, DashCanvasViewSpec, DashConfig, DashViewState, DashModel} from '../';
@@ -16,6 +19,7 @@ import {createObservableRef} from '@xh/hoist/utils/react';
16
19
  import {
17
20
  defaultsDeep,
18
21
  find,
22
+ omit,
19
23
  uniqBy,
20
24
  times,
21
25
  without,
@@ -48,8 +52,42 @@ export interface DashCanvasConfig extends DashConfig<DashCanvasViewSpec, DashCan
48
52
  /** Maximum number of rows permitted for this container. Default `Infinity`. */
49
53
  maxRows?: number;
50
54
 
51
- /** Padding inside the container [x, y] in pixels. Defaults to same as `margin`. */
55
+ /** Padding inside the container [x, y] in pixels. Default `[0, 0]`. */
52
56
  containerPadding?: [number, number];
57
+
58
+ /**
59
+ * Whether the canvas should accept drag-and-drop of views from outside
60
+ * the canvas. Default false.
61
+ */
62
+ droppable?: boolean;
63
+
64
+ /**
65
+ * Optional Callback to invoke after a view is successfully dropped onto the canvas.
66
+ */
67
+ onDropDone?: (viewModel: DashCanvasViewModel) => void;
68
+
69
+ /**
70
+ * Optional callback to invoke when an item is dragged over the canvas. This may be used to
71
+ * customize how the size of the dropping placeholder is calculated. The callback should
72
+ * return an object with optional properties indicating the desired width, height (in grid units),
73
+ * and offset (in pixels) of the dropping placeholder.
74
+ * If not provided, Hoist's own default logic will be used.
75
+ */
76
+ onDropDragOver?: (e: DragEvent) =>
77
+ | {
78
+ w?: number;
79
+ h?: number;
80
+ dragOffsetX?: number;
81
+ dragOffsetY?: number;
82
+ }
83
+ | false
84
+ | void;
85
+
86
+ /**
87
+ * Whether an overlay with an Add View button should be rendered
88
+ * when the canvas is empty. Default true.
89
+ */
90
+ showAddViewButtonWhenEmpty?: boolean;
53
91
  }
54
92
 
55
93
  export interface DashCanvasItemState {
@@ -82,11 +120,16 @@ export class DashCanvasModel
82
120
  @bindable compact: boolean;
83
121
  @bindable.ref margin: [number, number]; // [x, y]
84
122
  @bindable.ref containerPadding: [number, number]; // [x, y]
123
+ @bindable showAddViewButtonWhenEmpty: boolean;
85
124
 
86
125
  //-----------------------------
87
126
  // Public properties
88
127
  //-----------------------------
128
+ DROPPING_ELEM_ID = '__dropping-elem__';
89
129
  maxRows: number;
130
+ droppable: boolean;
131
+ onDropDone: (viewModel: DashCanvasViewModel) => void;
132
+ draggedInView: DashCanvasItemState;
90
133
 
91
134
  /** Current number of rows in canvas */
92
135
  get rows(): number {
@@ -106,21 +149,27 @@ export class DashCanvasModel
106
149
  private isLoadingState: boolean;
107
150
 
108
151
  get rglLayout() {
109
- return this.layout.map(it => {
110
- const dashCanvasView = this.getView(it.i),
111
- {autoHeight, viewSpec} = dashCanvasView;
112
-
113
- return {
114
- ...it,
115
- resizeHandles: autoHeight
116
- ? ['w', 'e']
117
- : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'],
118
- maxH: viewSpec.maxHeight,
119
- minH: viewSpec.minHeight,
120
- maxW: viewSpec.maxWidth,
121
- minW: viewSpec.minWidth
122
- };
123
- });
152
+ return this.layout
153
+ .map(it => {
154
+ const dashCanvasView = this.getView(it.i);
155
+
156
+ // `dashCanvasView` will not be found if `it` is a dropping element.
157
+ if (!dashCanvasView) return null;
158
+
159
+ const {autoHeight, viewSpec} = dashCanvasView;
160
+
161
+ return {
162
+ ...it,
163
+ resizeHandles: autoHeight
164
+ ? ['w', 'e']
165
+ : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'],
166
+ maxH: viewSpec.maxHeight,
167
+ minH: viewSpec.minHeight,
168
+ maxW: viewSpec.maxWidth,
169
+ minW: viewSpec.minWidth
170
+ };
171
+ })
172
+ .filter(Boolean);
124
173
  }
125
174
 
126
175
  constructor({
@@ -138,8 +187,12 @@ export class DashCanvasModel
138
187
  compact = true,
139
188
  margin = [10, 10],
140
189
  maxRows = Infinity,
141
- containerPadding = margin,
142
- extraMenuItems
190
+ containerPadding = [0, 0],
191
+ extraMenuItems,
192
+ showAddViewButtonWhenEmpty = true,
193
+ droppable = false,
194
+ onDropDone,
195
+ onDropDragOver
143
196
  }: DashCanvasConfig) {
144
197
  super();
145
198
  makeObservable(this);
@@ -182,11 +235,15 @@ export class DashCanvasModel
182
235
  this.maxRows = maxRows;
183
236
  this.containerPadding = containerPadding;
184
237
  this.margin = margin;
185
- this.containerPadding = containerPadding;
186
238
  this.compact = compact;
187
239
  this.emptyText = emptyText;
188
240
  this.addViewButtonText = addViewButtonText;
189
241
  this.extraMenuItems = extraMenuItems;
242
+ this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty;
243
+ this.droppable = droppable;
244
+ this.onDropDone = onDropDone;
245
+ // Override default onDropDragOver if provided
246
+ if (onDropDragOver) this.onDropDragOver = onDropDragOver;
190
247
 
191
248
  this.loadState(initialState);
192
249
  this.state = this.buildState();
@@ -312,6 +369,56 @@ export class DashCanvasModel
312
369
  this.getView(id)?.ensureVisible();
313
370
  }
314
371
 
372
+ onDrop(rglLayout: LayoutItem[], layoutItem: LayoutItem, evt: Event) {
373
+ throwIf(
374
+ !this.draggedInView,
375
+ `No draggedInView set on DashCanvasModel prior to onDrop operation.
376
+ Typically a developer would set this in response to dragstart events from
377
+ a DashViewTray or similar component.`
378
+ );
379
+
380
+ const {viewSpecId, title, state} = this.draggedInView,
381
+ layout = omit(layoutItem, 'i'),
382
+ newViewModel: DashCanvasViewModel = this.addViewInternal(viewSpecId, {
383
+ title,
384
+ state,
385
+ layout
386
+ }),
387
+ droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID);
388
+
389
+ // Change ID of dropping item to the new view's id
390
+ // so that the new view goes where the dropping item is.
391
+ droppingItem.i = newViewModel.id;
392
+
393
+ // must wait a tick for RGL to settle
394
+ wait().then(() => {
395
+ this.draggedInView = null;
396
+ this.onRglLayoutChange(rglLayout);
397
+ this.onDropDone?.(newViewModel);
398
+ });
399
+ }
400
+
401
+ setDraggedInView(view?: DashCanvasItemState) {
402
+ this.draggedInView = view;
403
+ }
404
+
405
+ onDropDragOver(evt: DragEvent):
406
+ | {
407
+ w?: number;
408
+ h?: number;
409
+ dragOffsetX?: number;
410
+ dragOffsetY?: number;
411
+ }
412
+ | false
413
+ | void {
414
+ if (!this.draggedInView) return false;
415
+
416
+ return {
417
+ w: this.draggedInView.layout.w,
418
+ h: this.draggedInView.layout.h
419
+ };
420
+ }
421
+
315
422
  //------------------------
316
423
  // Persistable Interface
317
424
  //------------------------
@@ -384,13 +491,19 @@ export class DashCanvasModel
384
491
  return model;
385
492
  }
386
493
 
387
- onRglLayoutChange(rglLayout) {
494
+ onRglLayoutChange(rglLayout: LayoutItem[]) {
388
495
  rglLayout = rglLayout.map(it => pick(it, ['i', 'x', 'y', 'w', 'h']));
496
+
497
+ // Early out if RGL is changing layout as user is dragging droppable
498
+ // item around the canvas. This will be called again once dragging
499
+ // has stopped and user has dropped the item onto the canvas.
500
+ if (rglLayout.some(it => it.i === this.DROPPING_ELEM_ID)) return;
501
+
389
502
  this.setLayout(rglLayout);
390
503
  }
391
504
 
392
505
  @action
393
- private setLayout(layout) {
506
+ private setLayout(layout: LayoutItem[]) {
394
507
  layout = sortBy(layout, 'i');
395
508
  const layoutChanged = !isEqual(layout, this.layout);
396
509
  if (!layoutChanged) return;
@@ -492,6 +605,7 @@ export class DashCanvasModel
492
605
  }
493
606
  }
494
607
  }
608
+
495
609
  const checkPosition = (originX, originY) => {
496
610
  for (let y = originY; y < originY + height; y++) {
497
611
  for (let x = originX; x < originX + width; x++) {
@@ -31,7 +31,7 @@ export const headerFilter = hoistCmp.factory({
31
31
  onClick: stopPropagation,
32
32
  onDoubleClick: stopPropagation,
33
33
  headerItems: [switcher()],
34
- item: tabContainer(),
34
+ item: tabContainer({switcher: false}),
35
35
  bbar: bbar(),
36
36
  hotkeys: [
37
37
  {
@@ -116,7 +116,6 @@ export class HeaderFilterModel extends HoistModel {
116
116
  this.valuesTabModel = enableValues ? new ValuesTabModel(this) : null;
117
117
  this.customTabModel = new CustomTabModel(this);
118
118
  this.tabContainerModel = new TabContainerModel({
119
- switcher: false,
120
119
  tabs: [
121
120
  {
122
121
  id: 'valuesFilter',
@@ -81,13 +81,13 @@ export interface PanelProps extends HoistProps<PanelModel>, Omit<BoxProps, 'titl
81
81
  * A toolbar to be docked at the top of the panel.
82
82
  * If specified as an array, items will be passed as children to a Toolbar component.
83
83
  */
84
- tbar?: Some<ReactNode>;
84
+ tbar?: ReactNode;
85
85
 
86
86
  /**
87
87
  * A toolbar to be docked at the bottom of the panel.
88
88
  * If specified as an array, items will be passed as children to a Toolbar component.
89
89
  */
90
- bbar?: Some<ReactNode>;
90
+ bbar?: ReactNode;
91
91
 
92
92
  /** Title text added to the panel's header. */
93
93
  title?: ReactNode;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {grid} from '@xh/hoist/cmp/grid';
9
9
  import {fragment} from '@xh/hoist/cmp/layout';
10
- import {hoistCmp, HoistProps, PlainObject, Some, uses} from '@xh/hoist/core';
10
+ import {hoistCmp, HoistProps, PlainObject, uses} from '@xh/hoist/core';
11
11
  import {MaskProps} from '@xh/hoist/cmp/mask';
12
12
  import {panel, PanelProps} from '@xh/hoist/desktop/cmp/panel';
13
13
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
@@ -21,8 +21,7 @@ import {restGridToolbar} from './impl/RestGridToolbar';
21
21
  import {RestGridModel} from './RestGridModel';
22
22
 
23
23
  export interface RestGridProps
24
- extends HoistProps<RestGridModel>,
25
- Omit<PanelProps, 'model' | 'modelConfig' | 'modelRef'> {
24
+ extends HoistProps<RestGridModel>, Omit<PanelProps, 'model' | 'modelConfig' | 'modelRef'> {
26
25
  /**
27
26
  * This constitutes an 'escape hatch' for applications that need to get to the underlying
28
27
  * AG Grid API. Use with care - settings made here might be overwritten and/or interfere with
@@ -34,7 +33,7 @@ export interface RestGridProps
34
33
  * Optional components rendered adjacent to the top toolbar's action buttons.
35
34
  * See also {@link tbar} to take full control of the toolbar.
36
35
  */
37
- extraToolbarItems?: Some<ReactNode> | (() => Some<ReactNode>);
36
+ extraToolbarItems?: ReactNode | (() => ReactNode);
38
37
 
39
38
  /** Classname to be passed to RestForm. */
40
39
  formClassName?: string;
@@ -51,7 +50,7 @@ export interface RestGridProps
51
50
  * configs `toolbarActions`, `filterFields`, and `showRefreshButton`. If specified as an array,
52
51
  * will be passed as children to a Toolbar component.
53
52
  */
54
- tbar?: Some<ReactNode>;
53
+ tbar?: ReactNode;
55
54
  }
56
55
 
57
56
  export const [RestGrid, restGrid] = hoistCmp.withFactory<RestGridProps>({
@@ -6,15 +6,19 @@
6
6
  */
7
7
  import composeRefs from '@seznam/compose-react-refs';
8
8
  import {box, div, hframe, span} from '@xh/hoist/cmp/layout';
9
- import {TabContainerModel, TabSwitcherProps} from '@xh/hoist/cmp/tab';
9
+ import {TabContainerModel} from '@xh/hoist/cmp/tab';
10
+ import {TabSwitcherProps} from '@xh/hoist/cmp/tab/Types';
10
11
  import {hoistCmp, HoistModel, useLocalModel, uses} from '@xh/hoist/core';
11
12
  import {button} from '@xh/hoist/desktop/cmp/button';
12
13
  import '@xh/hoist/desktop/register';
14
+ import {contextMenu} from '@xh/hoist/desktop/cmp/contextmenu';
15
+ import {getContextMenuItem} from '@xh/hoist/desktop/cmp/tab/impl/TabContextMenuItems';
13
16
  import {Icon} from '@xh/hoist/icon';
14
17
  import {
15
18
  menu,
16
19
  menuItem,
17
20
  popover,
21
+ showContextMenu,
18
22
  tab as bpTab,
19
23
  tabs as bpTabs,
20
24
  tooltip as bpTooltip
@@ -67,7 +71,7 @@ export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>
67
71
  'Unsupported value for orientation.'
68
72
  );
69
73
 
70
- const {tabs, activeTabId} = model,
74
+ const {tabs, activeTabId, switcherConfig} = model,
71
75
  layoutProps = getLayoutProps(props),
72
76
  vertical = ['left', 'right'].includes(orientation),
73
77
  impl = useLocalModel(() => new TabSwitcherLocalModel(model, enableOverflow, vertical));
@@ -119,7 +123,18 @@ export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>
119
123
  })
120
124
  ]
121
125
  })
122
- })
126
+ }),
127
+ onContextMenu: e => {
128
+ const domRect = e.currentTarget.getBoundingClientRect(),
129
+ menuItems = (switcherConfig.extraMenuItems ?? []).map(it =>
130
+ getContextMenuItem(it, {contextMenuEvent: e, tab})
131
+ );
132
+ if (isEmpty(menuItems)) return;
133
+ showContextMenu(contextMenu({menuItems}), {
134
+ left: orientation === 'left' ? domRect.right : domRect.left,
135
+ top: orientation === 'top' ? domRect.bottom : domRect.top
136
+ });
137
+ }
123
138
  });
124
139
  });
125
140
 
@@ -67,6 +67,7 @@
67
67
  align-items: baseline;
68
68
  font-family: var(--xh-tab-font-family);
69
69
  font-size: var(--xh-tab-font-size-px);
70
+ user-select: none;
70
71
 
71
72
  & > svg:first-child {
72
73
  margin: 0 0.4em 0 0;
@@ -0,0 +1,53 @@
1
+ .xh-dynamic-tab-switcher {
2
+ &:not(&--vertical) &__tabs {
3
+ overflow-x: auto;
4
+
5
+ .bp5-tab-list {
6
+ column-gap: var(--xh-pad-double-px);
7
+ }
8
+ }
9
+
10
+ &--vertical &__tabs {
11
+ overflow-y: auto;
12
+ }
13
+
14
+ &__tabs {
15
+ display: flex;
16
+ flex-direction: row;
17
+
18
+ &::-webkit-scrollbar {
19
+ display: none;
20
+ }
21
+
22
+ &__tab {
23
+ align-items: center;
24
+ user-select: none;
25
+
26
+ .xh-dynamic-tab-switcher--vertical & {
27
+ min-height: fit-content;
28
+ width: 100%;
29
+ }
30
+
31
+ &:not(&--active) {
32
+ cursor: pointer;
33
+ }
34
+
35
+ &--dragging {
36
+ background-color: var(--xh-menu-item-highlight-bg);
37
+ }
38
+
39
+ &__icon {
40
+ margin-right: 3px;
41
+ }
42
+
43
+ &__close-button {
44
+ align-self: center;
45
+ padding: 0 !important;
46
+ margin-left: 3px;
47
+ min-height: 15px;
48
+ min-width: 15px;
49
+ border-radius: 100% !important;
50
+ }
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,237 @@
1
+ import composeRefs from '@seznam/compose-react-refs';
2
+ import {box, div, hframe} from '@xh/hoist/cmp/layout';
3
+ import {TabContainerModel, TabModel} from '@xh/hoist/cmp/tab';
4
+ import {TabSwitcherProps} from '@xh/hoist/cmp/tab/Types';
5
+ import {
6
+ hoistCmp,
7
+ HoistModel,
8
+ HoistProps,
9
+ Side,
10
+ useContextModel,
11
+ useLocalModel,
12
+ uses
13
+ } from '@xh/hoist/core';
14
+ import {button} from '@xh/hoist/desktop/cmp/button';
15
+ import {contextMenu} from '@xh/hoist/desktop/cmp/contextmenu';
16
+ import {scroller} from '@xh/hoist/desktop/cmp/tab/dynamic/scroller/Scroller';
17
+ import {ScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/scroller/ScrollerModel';
18
+ import {Icon} from '@xh/hoist/icon';
19
+ import {showContextMenu, tooltip as bpTooltip} from '@xh/hoist/kit/blueprint';
20
+ import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
21
+ import {wait} from '@xh/hoist/promise';
22
+ import {consumeEvent} from '@xh/hoist/utils/js';
23
+ import {getLayoutProps} from '@xh/hoist/utils/react';
24
+ import classNames from 'classnames';
25
+ import {first, isFinite, last} from 'lodash';
26
+ import {computed} from 'mobx';
27
+ import {CSSProperties, ReactElement, Ref, useEffect, useRef} from 'react';
28
+ import {DynamicTabSwitcherModel} from './DynamicTabSwitcherModel';
29
+ import './DynamicTabSwitcher.scss';
30
+
31
+ /**
32
+ * A tab switcher that displays tabs as draggable items in a horizontal list.
33
+ * Tabs can be added, removed, reordered and favorited with persistence.
34
+ */
35
+ export const [DynamicTabSwitcher, dynamicTabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>({
36
+ className: 'xh-dynamic-tab-switcher',
37
+ displayName: 'DynamicTabSwitcher',
38
+ model: uses(TabContainerModel),
39
+ render({className, orientation, ...props}) {
40
+ const impl = useLocalModel(DynamicTabSwitcherLocalModel);
41
+ return scroller({
42
+ className: classNames(className, impl.isVertical && `${className}--vertical`),
43
+ content: tabs,
44
+ contentProps: {localModel: impl},
45
+ orientation: ['left', 'right'].includes(orientation) ? 'vertical' : 'horizontal',
46
+ ...getLayoutProps(props)
47
+ });
48
+ }
49
+ });
50
+
51
+ /**
52
+ * Minimal local model to avoid prop drilling.
53
+ */
54
+ class DynamicTabSwitcherLocalModel extends HoistModel {
55
+ @computed
56
+ get isVertical(): boolean {
57
+ return ['left', 'right'].includes(this.props.orientation);
58
+ }
59
+
60
+ get props(): TabSwitcherProps {
61
+ const ret = this.componentProps as TabSwitcherProps;
62
+ return {
63
+ ...ret,
64
+ orientation: ret.orientation ?? 'top'
65
+ };
66
+ }
67
+ }
68
+
69
+ interface TabsProps extends HoistProps<DynamicTabSwitcherModel> {
70
+ localModel: DynamicTabSwitcherLocalModel;
71
+ ref: Ref<HTMLDivElement>;
72
+ }
73
+
74
+ const tabs = hoistCmp.factory<TabsProps>({
75
+ model: uses(DynamicTabSwitcherModel),
76
+ render({localModel, model}, ref) {
77
+ const {visibleTabs} = model,
78
+ {isVertical, props} = localModel,
79
+ layoutProps = getLayoutProps(props);
80
+ return dragDropContext({
81
+ onDragEnd: result => model.onDragEnd(result),
82
+ item: droppable({
83
+ droppableId: model.xhId,
84
+ direction: isVertical ? 'vertical' : 'horizontal',
85
+ children: provided =>
86
+ box({
87
+ ...layoutProps,
88
+ className: `xh-dynamic-tab-switcher__tabs xh-tab-switcher xh-tab-switcher--${props.orientation}`,
89
+ ref: composeRefs(provided.innerRef, ref),
90
+ item: div({
91
+ className: classNames('bp5-tabs', isVertical && 'bp5-vertical'),
92
+ item: div({
93
+ className: 'bp5-tab-list',
94
+ items: [
95
+ visibleTabs.map((tab, index) =>
96
+ tabCmp({key: tab.id, localModel, tab, index})
97
+ ),
98
+ provided.placeholder
99
+ ]
100
+ })
101
+ })
102
+ })
103
+ })
104
+ });
105
+ }
106
+ });
107
+
108
+ interface TabProps extends HoistProps<DynamicTabSwitcherModel> {
109
+ tab: TabModel;
110
+ index: number;
111
+ localModel: DynamicTabSwitcherLocalModel;
112
+ }
113
+
114
+ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
115
+ const isActive = model.isTabActive(tab.id),
116
+ isCloseable =
117
+ tab.disabled ||
118
+ model.enabledVisibleTabs.filter(it => it instanceof TabModel).length > 1,
119
+ tabRef = useRef<HTMLDivElement>(),
120
+ scrollerModel = useContextModel(ScrollerModel),
121
+ {showScrollButtons} = scrollerModel,
122
+ {disabled, icon, tooltip} = tab,
123
+ isFavorite = model.isTabFavorite(tab.id),
124
+ {isVertical, props} = localModel,
125
+ {tabWidth, tabMinWidth, tabMaxWidth} = props;
126
+
127
+ // Handle tab sizing props
128
+ const tabStyle: CSSProperties = {};
129
+ if (!isVertical && isFinite(tabWidth)) tabStyle.width = tabWidth + 'px';
130
+ if (!isVertical && isFinite(tabMinWidth)) tabStyle.minWidth = tabMinWidth + 'px';
131
+ if (!isVertical && isFinite(tabMaxWidth)) tabStyle.maxWidth = tabMaxWidth + 'px';
132
+
133
+ // Handle this at the component level rather than in the model since they are not "linked"
134
+ useEffect(() => {
135
+ if (isActive && showScrollButtons) {
136
+ // Wait a tick for scroll buttons to render, then scroll to the active tab
137
+ wait().then(() => tabRef.current.scrollIntoView({behavior: 'smooth'}));
138
+ }
139
+ }, [isActive, showScrollButtons]);
140
+
141
+ return draggable({
142
+ key: tab.id,
143
+ draggableId: tab.id,
144
+ index,
145
+ children: (provided, snapshot) =>
146
+ hframe({
147
+ className: classNames(
148
+ 'xh-dynamic-tab-switcher__tabs__tab',
149
+ isActive && 'xh-dynamic-tab-switcher__tabs__tab--active',
150
+ snapshot.isDragging && 'xh-dynamic-tab-switcher__tabs__tab--dragging'
151
+ ),
152
+ onClick: () => {
153
+ if (!disabled) model.activate(tab.id);
154
+ },
155
+ onContextMenu: e => {
156
+ const domRect = e.currentTarget.getBoundingClientRect();
157
+ showContextMenu(
158
+ contextMenu({
159
+ menuItems: model.getContextMenuItems(e, tab)
160
+ }),
161
+ {
162
+ left: props.orientation === 'left' ? domRect.right : domRect.left,
163
+ top: props.orientation === 'top' ? domRect.bottom : domRect.top
164
+ }
165
+ );
166
+ },
167
+ ref: composeRefs(provided.innerRef, tabRef),
168
+ ...provided.draggableProps,
169
+ ...provided.dragHandleProps,
170
+ style: getStyles(isVertical, provided.draggableProps.style),
171
+ items: [
172
+ div({
173
+ 'aria-selected': isActive,
174
+ 'aria-disabled': disabled,
175
+ className: 'bp5-tab',
176
+ item: bpTooltip({
177
+ content: tooltip as ReactElement,
178
+ disabled: !tooltip,
179
+ hoverOpenDelay: 1000,
180
+ position: flipOrientation(props.orientation),
181
+ item: hframe({
182
+ className: 'xh-tab-switcher__tab',
183
+ style: tabStyle,
184
+ tabIndex: -1,
185
+ items: [
186
+ div({
187
+ className: 'xh-dynamic-tab-switcher__tabs__tab__icon',
188
+ item: icon,
189
+ omit: !icon
190
+ }),
191
+ tab.title,
192
+ button({
193
+ className:
194
+ 'xh-dynamic-tab-switcher__tabs__tab__close-button',
195
+ icon: Icon.x({size: 'sm'}),
196
+ title: 'Remove Tab',
197
+ minimal: true,
198
+ onClick: e => {
199
+ consumeEvent(e);
200
+ model.hide(tab.id);
201
+ },
202
+ omit: isFavorite || !isCloseable
203
+ })
204
+ ]
205
+ })
206
+ })
207
+ })
208
+ ]
209
+ })
210
+ });
211
+ });
212
+
213
+ const getStyles = (isVertical: boolean, style: CSSProperties): CSSProperties => {
214
+ const {transform} = style;
215
+ if (!transform) return style;
216
+
217
+ return {
218
+ ...style,
219
+ // Only drag in one axis
220
+ transform: isVertical
221
+ ? `translate(0, ${last(transform.split(','))}`
222
+ : `${first(transform.split(','))}, 0)`
223
+ };
224
+ };
225
+
226
+ function flipOrientation(orientation: Side) {
227
+ switch (orientation) {
228
+ case 'top':
229
+ return 'bottom';
230
+ case 'bottom':
231
+ return 'top';
232
+ case 'left':
233
+ return 'right';
234
+ case 'right':
235
+ return 'left';
236
+ }
237
+ }