@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.
- package/CHANGELOG.md +16 -0
- package/admin/AppComponent.ts +9 -1
- package/admin/AppModel.ts +0 -4
- package/admin/tabs/cluster/instances/InstancesTab.ts +1 -1
- package/admin/tabs/cluster/instances/InstancesTabModel.ts +0 -1
- package/admin/tabs/userData/roles/details/RoleDetailsModel.ts +0 -1
- package/build/types/cmp/tab/TabContainer.d.ts +19 -4
- package/build/types/cmp/tab/TabContainerModel.d.ts +18 -19
- package/build/types/cmp/tab/Types.d.ts +61 -0
- package/build/types/cmp/tab/index.d.ts +1 -1
- package/build/types/core/elem.d.ts +3 -3
- package/build/types/data/RecordAction.d.ts +4 -1
- package/build/types/desktop/cmp/dash/canvas/DashCanvas.d.ts +3 -2
- package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +45 -3
- package/build/types/desktop/cmp/panel/Panel.d.ts +2 -2
- package/build/types/desktop/cmp/rest/RestGrid.d.ts +3 -3
- package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
- package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcher.d.ts +7 -0
- package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.d.ts +30 -0
- package/build/types/desktop/cmp/tab/dynamic/scroller/Scroller.d.ts +19 -0
- package/build/types/desktop/cmp/tab/dynamic/scroller/ScrollerModel.d.ts +23 -0
- package/build/types/desktop/cmp/tab/impl/Tab.d.ts +7 -2
- package/build/types/desktop/cmp/tab/impl/TabContainer.d.ts +1 -1
- package/build/types/desktop/cmp/tab/impl/TabContextMenuItems.d.ts +4 -0
- package/build/types/desktop/cmp/tab/index.d.ts +1 -0
- package/build/types/dynamics/desktop.d.ts +1 -0
- package/build/types/mobile/cmp/panel/Panel.d.ts +2 -2
- package/build/types/mobile/cmp/tab/impl/TabContainer.d.ts +1 -1
- package/cmp/tab/TabContainer.ts +19 -4
- package/cmp/tab/TabContainerModel.ts +113 -54
- package/cmp/tab/TabModel.ts +1 -2
- package/cmp/tab/Types.ts +80 -0
- package/cmp/tab/index.ts +1 -1
- package/core/elem.ts +5 -5
- package/data/RecordAction.ts +4 -1
- package/desktop/appcontainer/AppContainer.ts +3 -2
- package/desktop/cmp/dash/canvas/DashCanvas.ts +57 -35
- package/desktop/cmp/dash/canvas/DashCanvasModel.ts +135 -21
- package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilter.ts +1 -1
- package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +0 -1
- package/desktop/cmp/panel/Panel.ts +2 -2
- package/desktop/cmp/rest/RestGrid.ts +4 -5
- package/desktop/cmp/tab/TabSwitcher.ts +18 -3
- package/desktop/cmp/tab/Tabs.scss +1 -0
- package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss +53 -0
- package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +237 -0
- package/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts +167 -0
- package/desktop/cmp/tab/dynamic/scroller/Scroller.ts +69 -0
- package/desktop/cmp/tab/dynamic/scroller/ScrollerModel.ts +92 -0
- package/desktop/cmp/tab/impl/Tab.ts +30 -6
- package/desktop/cmp/tab/impl/TabContainer.ts +34 -9
- package/desktop/cmp/tab/impl/TabContextMenuItems.ts +21 -0
- package/desktop/cmp/tab/index.ts +1 -0
- package/dynamics/desktop.ts +2 -0
- package/mobile/cmp/panel/Panel.ts +2 -2
- package/mobile/cmp/tab/impl/TabContainer.ts +16 -9
- package/package.json +2 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/build/types/cmp/tab/TabSwitcherProps.d.ts +0 -16
- 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.
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 =
|
|
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++) {
|
|
@@ -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?:
|
|
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?:
|
|
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,
|
|
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?:
|
|
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?:
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|