@xh/hoist 79.0.0-SNAPSHOT.1766020485210 → 79.0.0-SNAPSHOT.1766094533168
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 +8 -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/data/RecordAction.d.ts +4 -1
- 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/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/data/RecordAction.ts +4 -1
- package/desktop/appcontainer/AppContainer.ts +3 -2
- 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/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/tab/impl/TabContainer.ts +16 -9
- package/package.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/build/types/cmp/tab/TabSwitcherProps.d.ts +0 -16
- package/cmp/tab/TabSwitcherProps.ts +0 -28
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {TabContainerModel, TabModel} from '@xh/hoist/cmp/tab';
|
|
2
|
+
import {
|
|
3
|
+
IDynamicTabSwitcherModel,
|
|
4
|
+
TabSwitcherConfig,
|
|
5
|
+
TabSwitcherMenuContext
|
|
6
|
+
} from '@xh/hoist/cmp/tab/Types';
|
|
7
|
+
import {HoistModel, MenuItemLike, MenuToken, ReactionSpec, XH} from '@xh/hoist/core';
|
|
8
|
+
import {getContextMenuItem} from '@xh/hoist/desktop/cmp/tab/impl/TabContextMenuItems';
|
|
9
|
+
import {Icon} from '@xh/hoist/icon';
|
|
10
|
+
import {makeObservable} from '@xh/hoist/mobx';
|
|
11
|
+
import {compact, find} from 'lodash';
|
|
12
|
+
import {action, computed, observable, when} from 'mobx';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* State management for the DynamicTabSwitcher component.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export class DynamicTabSwitcherModel extends HoistModel implements IDynamicTabSwitcherModel {
|
|
20
|
+
declare config: TabSwitcherConfig;
|
|
21
|
+
|
|
22
|
+
private readonly extraMenuItems: Array<MenuItemLike<MenuToken, TabSwitcherMenuContext>>;
|
|
23
|
+
private readonly tabContainerModel: TabContainerModel;
|
|
24
|
+
@observable.ref private visibleTabState: TabState[];
|
|
25
|
+
|
|
26
|
+
@computed
|
|
27
|
+
get favoriteTabIds(): string[] {
|
|
28
|
+
return this.visibleTabState.filter(it => it.isFavorite).map(it => it.tabId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@computed
|
|
32
|
+
get visibleTabs(): TabModel[] {
|
|
33
|
+
return compact(this.visibleTabState.map(it => this.tabContainerModel.findTab(it.tabId)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@computed
|
|
37
|
+
get enabledVisibleTabs(): TabModel[] {
|
|
38
|
+
return this.visibleTabs.filter(it => !it.disabled);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
{extraMenuItems = [], initialFavorites = []}: TabSwitcherConfig,
|
|
43
|
+
tabContainerModel: TabContainerModel
|
|
44
|
+
) {
|
|
45
|
+
super();
|
|
46
|
+
makeObservable(this);
|
|
47
|
+
|
|
48
|
+
this.extraMenuItems = extraMenuItems;
|
|
49
|
+
this.tabContainerModel = tabContainerModel;
|
|
50
|
+
this.visibleTabState = this.getValidTabIds(initialFavorites).map(tabId => ({
|
|
51
|
+
tabId,
|
|
52
|
+
isFavorite: true
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Wait for router to start before observing active tab
|
|
56
|
+
when(
|
|
57
|
+
() => XH.appIsRunning,
|
|
58
|
+
() => this.addReaction(this.activeTabReaction())
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
isTabActive(tabId: string): boolean {
|
|
63
|
+
return this.tabContainerModel.activeTabId === tabId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isTabFavorite(tabId: string): boolean {
|
|
67
|
+
return !!find(this.visibleTabState, {tabId})?.isFavorite;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@action
|
|
71
|
+
toggleTabFavorite(tabId: string) {
|
|
72
|
+
this.visibleTabState = !this.isTabVisible(tabId)
|
|
73
|
+
? [...this.visibleTabState, {tabId, isFavorite: true}]
|
|
74
|
+
: this.visibleTabState.map(it =>
|
|
75
|
+
it.tabId === tabId ? {tabId, isFavorite: !this.isTabFavorite(tabId)} : it
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
activate(tabId: string) {
|
|
80
|
+
this.tabContainerModel.activateTab(tabId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@action
|
|
84
|
+
hide(tabId: string) {
|
|
85
|
+
const {enabledVisibleTabs, tabContainerModel} = this;
|
|
86
|
+
if (tabContainerModel.activeTabId === tabId) {
|
|
87
|
+
const visitableTabs = enabledVisibleTabs.filter(tab => tab instanceof TabModel),
|
|
88
|
+
activeTabIdx = visitableTabs.findIndex(tab => tab.id === tabId),
|
|
89
|
+
toActivate =
|
|
90
|
+
visitableTabs[
|
|
91
|
+
activeTabIdx + (activeTabIdx === visitableTabs.length - 1 ? -1 : 1)
|
|
92
|
+
];
|
|
93
|
+
if (toActivate) tabContainerModel.activateTab(toActivate);
|
|
94
|
+
}
|
|
95
|
+
this.visibleTabState = this.visibleTabState.filter(it => it.tabId !== tabId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getContextMenuItems(
|
|
99
|
+
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
|
100
|
+
tab: TabModel
|
|
101
|
+
): Array<MenuItemLike<MenuToken, TabSwitcherMenuContext>> {
|
|
102
|
+
const isFavorite = this.isTabFavorite(tab.id);
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
icon: Icon.favorite({prefix: isFavorite ? 'fal' : 'fas'}),
|
|
106
|
+
text: isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
|
|
107
|
+
actionFn: () => this.toggleTabFavorite(tab.id)
|
|
108
|
+
},
|
|
109
|
+
...this.extraMenuItems.map(item => getContextMenuItem(item, {contextMenuEvent: e, tab}))
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@action
|
|
114
|
+
setFavoriteTabIds(tabIds: string[]) {
|
|
115
|
+
const visibleTabState = this.getValidTabIds(tabIds).map(tabId => ({
|
|
116
|
+
tabId,
|
|
117
|
+
isFavorite: true
|
|
118
|
+
})),
|
|
119
|
+
{activeTab} = this.tabContainerModel;
|
|
120
|
+
if (activeTab && !activeTab.excludeFromSwitcher && !tabIds.includes(activeTab.id)) {
|
|
121
|
+
visibleTabState.push({tabId: activeTab.id, isFavorite: false});
|
|
122
|
+
}
|
|
123
|
+
this.visibleTabState = visibleTabState;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@action
|
|
127
|
+
onDragEnd(result) {
|
|
128
|
+
if (!result.destination) return;
|
|
129
|
+
const visibleTabState = [...this.visibleTabState],
|
|
130
|
+
[removed] = visibleTabState.splice(result.source.index, 1);
|
|
131
|
+
visibleTabState.splice(result.destination.index, 0, removed);
|
|
132
|
+
this.visibleTabState = visibleTabState;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -------------------------------
|
|
136
|
+
// Implementation
|
|
137
|
+
// -------------------------------
|
|
138
|
+
private activeTabReaction(): ReactionSpec<TabModel> {
|
|
139
|
+
return {
|
|
140
|
+
track: () => this.tabContainerModel.activeTab,
|
|
141
|
+
run: ({id: tabId, excludeFromSwitcher}) => {
|
|
142
|
+
if (!excludeFromSwitcher && !this.isTabVisible(tabId)) {
|
|
143
|
+
this.visibleTabState = [...this.visibleTabState, {tabId, isFavorite: false}];
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
fireImmediately: true
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private getValidTabIds(tabIds: string[]): string[] {
|
|
151
|
+
return tabIds.filter(id => this.isValidTabId(id));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private isValidTabId(tabId: string): boolean {
|
|
155
|
+
const tabModel = this.tabContainerModel.findTab(tabId);
|
|
156
|
+
return !!(tabModel && !tabModel.excludeFromSwitcher);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private isTabVisible(tabId: string): boolean {
|
|
160
|
+
return this.visibleTabState.some(it => it.tabId === tabId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface TabState {
|
|
165
|
+
tabId: string;
|
|
166
|
+
isFavorite: boolean;
|
|
167
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import composeRefs from '@seznam/compose-react-refs';
|
|
2
|
+
import {hbox, vbox} from '@xh/hoist/cmp/layout';
|
|
3
|
+
import {BoxProps, creates, hoistCmp, HoistProps} from '@xh/hoist/core';
|
|
4
|
+
import {button} from '@xh/hoist/desktop/cmp/button';
|
|
5
|
+
import {ScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/scroller/ScrollerModel';
|
|
6
|
+
import {Icon} from '@xh/hoist/icon';
|
|
7
|
+
import {useOnResize} from '@xh/hoist/utils/react';
|
|
8
|
+
import React, {Ref} from 'react';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A scroller component that displays a content component with directional scroll buttons when the
|
|
12
|
+
* content overflows the viewport.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ScrollerProps extends HoistProps<ScrollerModel>, Omit<BoxProps, 'content'> {
|
|
17
|
+
/** The content to be displayed within the scroller. */
|
|
18
|
+
content: React.FC<{ref: Ref<HTMLDivElement>}>;
|
|
19
|
+
/** Props to be passed to the content component. */
|
|
20
|
+
contentProps?: Record<string, any>;
|
|
21
|
+
/** Orientation of the scroller - horizontal (default) or vertical. */
|
|
22
|
+
orientation?: 'horizontal' | 'vertical';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const [Scroller, scroller] = hoistCmp.withFactory<ScrollerProps>({
|
|
26
|
+
displayName: 'Scroller',
|
|
27
|
+
model: creates(ScrollerModel, {publishMode: 'limited'}),
|
|
28
|
+
render({className, content, contentProps, model, orientation, ...layoutProps}) {
|
|
29
|
+
const {contentRef, isHorizontal} = model,
|
|
30
|
+
container = isHorizontal ? hbox : vbox;
|
|
31
|
+
return container({
|
|
32
|
+
...layoutProps,
|
|
33
|
+
className,
|
|
34
|
+
items: [
|
|
35
|
+
scrollButton({direction: 'backward', model}),
|
|
36
|
+
content({
|
|
37
|
+
...contentProps,
|
|
38
|
+
ref: composeRefs(
|
|
39
|
+
contentRef,
|
|
40
|
+
useOnResize(() => model.onViewportEvent())
|
|
41
|
+
)
|
|
42
|
+
}),
|
|
43
|
+
scrollButton({direction: 'forward', model})
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
interface ScrollButtonProps extends HoistProps<ScrollerModel> {
|
|
50
|
+
direction: 'forward' | 'backward';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const scrollButton = hoistCmp.factory<ScrollButtonProps>(({direction, model}) => {
|
|
54
|
+
if (!model.showScrollButtons) return null;
|
|
55
|
+
return button({
|
|
56
|
+
icon:
|
|
57
|
+
direction === 'backward'
|
|
58
|
+
? model.isHorizontal
|
|
59
|
+
? Icon.chevronLeft()
|
|
60
|
+
: Icon.chevronUp()
|
|
61
|
+
: model.isHorizontal
|
|
62
|
+
? Icon.chevronRight()
|
|
63
|
+
: Icon.chevronDown(),
|
|
64
|
+
disabled: direction === 'backward' ? model.isScrolledToStart : model.isScrolledToEnd,
|
|
65
|
+
onMouseDown: () => model.scroll(direction),
|
|
66
|
+
onMouseUp: () => model.stopScrolling(),
|
|
67
|
+
onMouseLeave: () => model.stopScrolling()
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {HoistModel} from '@xh/hoist/core';
|
|
2
|
+
import {makeObservable} from '@xh/hoist/mobx';
|
|
3
|
+
import {isNil} from 'lodash';
|
|
4
|
+
import {action, computed, observable} from 'mobx';
|
|
5
|
+
import {createRef} from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal model for the Scroller component. Used to manage the scroll state and provide
|
|
9
|
+
* scroll functionality. Uses animation frames to ensure smooth scrolling.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class ScrollerModel extends HoistModel {
|
|
14
|
+
contentRef = createRef<HTMLDivElement>();
|
|
15
|
+
|
|
16
|
+
@observable private scrollStart: number;
|
|
17
|
+
@observable private scrollSize: number;
|
|
18
|
+
@observable private clientSize: number;
|
|
19
|
+
|
|
20
|
+
private animationFrameId: number;
|
|
21
|
+
|
|
22
|
+
@computed
|
|
23
|
+
get showScrollButtons(): boolean {
|
|
24
|
+
return this.scrollSize > this.clientSize;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@computed
|
|
28
|
+
get isScrolledToStart(): boolean {
|
|
29
|
+
return this.scrollStart === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@computed
|
|
33
|
+
get isScrolledToEnd(): boolean {
|
|
34
|
+
// Allow for a 1px buffer to account for rounding errors
|
|
35
|
+
return this.scrollStart + this.clientSize >= this.scrollSize - 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isHorizontal(): boolean {
|
|
39
|
+
return this.componentProps.orientation !== 'vertical';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
makeObservable(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override afterLinked() {
|
|
48
|
+
this.contentRef.current.addEventListener('scroll', () => this.onViewportEvent());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
scroll(direction: 'forward' | 'backward') {
|
|
52
|
+
this.stopScrolling();
|
|
53
|
+
this.animationFrameId = window.requestAnimationFrame(() => {
|
|
54
|
+
const {current} = this.contentRef;
|
|
55
|
+
if (
|
|
56
|
+
!current ||
|
|
57
|
+
(direction === 'backward' && this.isScrolledToStart) ||
|
|
58
|
+
(direction === 'forward' && this.isScrolledToEnd)
|
|
59
|
+
) {
|
|
60
|
+
this.stopScrolling();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.isHorizontal) {
|
|
64
|
+
current.scrollLeft += direction === 'backward' ? -10 : 10;
|
|
65
|
+
} else {
|
|
66
|
+
current.scrollTop += direction === 'backward' ? -10 : 10;
|
|
67
|
+
}
|
|
68
|
+
this.scroll(direction);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stopScrolling() {
|
|
73
|
+
if (!isNil(this.animationFrameId)) {
|
|
74
|
+
window.cancelAnimationFrame(this.animationFrameId);
|
|
75
|
+
this.animationFrameId = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@action
|
|
80
|
+
onViewportEvent() {
|
|
81
|
+
const {contentRef, isHorizontal} = this,
|
|
82
|
+
{current} = contentRef;
|
|
83
|
+
this.scrollStart = isHorizontal ? current.scrollLeft : current.scrollTop;
|
|
84
|
+
this.scrollSize = isHorizontal ? current.scrollWidth : current.scrollHeight;
|
|
85
|
+
this.clientSize = isHorizontal ? current.clientWidth : current.clientHeight;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override destroy() {
|
|
89
|
+
this.stopScrolling();
|
|
90
|
+
super.destroy();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {frame} from '@xh/hoist/cmp/layout';
|
|
8
|
-
import {TabModel} from '@xh/hoist/cmp/tab';
|
|
9
|
-
import {
|
|
8
|
+
import {TabContainerProps, TabModel} from '@xh/hoist/cmp/tab';
|
|
9
|
+
import {
|
|
10
|
+
hoistCmp,
|
|
11
|
+
HoistProps,
|
|
12
|
+
PlainObject,
|
|
13
|
+
refreshContextView,
|
|
14
|
+
TestSupportProps,
|
|
15
|
+
uses
|
|
16
|
+
} from '@xh/hoist/core';
|
|
10
17
|
import {elementFromContent} from '@xh/hoist/utils/react';
|
|
18
|
+
import {isFunction} from 'lodash';
|
|
11
19
|
import {useRef} from 'react';
|
|
12
20
|
import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
|
|
13
21
|
|
|
@@ -21,13 +29,17 @@ import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
|
|
|
21
29
|
*
|
|
22
30
|
* @internal
|
|
23
31
|
*/
|
|
24
|
-
|
|
32
|
+
interface TabProps extends HoistProps<TabModel>, TestSupportProps {
|
|
33
|
+
childContainerProps?: TabContainerProps['childContainerProps'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const tab = hoistCmp.factory<TabProps>({
|
|
25
37
|
displayName: 'Tab',
|
|
26
38
|
className: 'xh-tab',
|
|
27
39
|
model: uses(TabModel, {publishMode: 'limited'}),
|
|
28
40
|
|
|
29
|
-
render({model, className, testId}) {
|
|
30
|
-
const {content, isActive, renderMode, refreshContextModel} = model,
|
|
41
|
+
render({model, childContainerProps, className, testId}) {
|
|
42
|
+
const {childContainerModel, content, isActive, id, renderMode, refreshContextModel} = model,
|
|
31
43
|
wasActivated = useRef(false);
|
|
32
44
|
|
|
33
45
|
if (!wasActivated.current && isActive) wasActivated.current = true;
|
|
@@ -39,13 +51,25 @@ export const tab = hoistCmp.factory({
|
|
|
39
51
|
return null;
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
let contentProps: PlainObject = {flex: 1};
|
|
55
|
+
if (childContainerModel) {
|
|
56
|
+
if (isFunction(childContainerProps)) {
|
|
57
|
+
contentProps = {
|
|
58
|
+
...contentProps,
|
|
59
|
+
...childContainerProps({tabId: id, depth: childContainerModel.depth})
|
|
60
|
+
};
|
|
61
|
+
} else if (childContainerProps) {
|
|
62
|
+
contentProps = {...contentProps, ...childContainerProps};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
return frame({
|
|
43
67
|
display: isActive ? 'flex' : 'none',
|
|
44
68
|
className,
|
|
45
69
|
testId,
|
|
46
70
|
item: refreshContextView({
|
|
47
71
|
model: refreshContextModel,
|
|
48
|
-
item: errorBoundary(elementFromContent(content,
|
|
72
|
+
item: errorBoundary(elementFromContent(content, contentProps))
|
|
49
73
|
})
|
|
50
74
|
});
|
|
51
75
|
}
|