@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +8 -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/data/RecordAction.d.ts +4 -1
  12. package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
  13. package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcher.d.ts +7 -0
  14. package/build/types/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.d.ts +30 -0
  15. package/build/types/desktop/cmp/tab/dynamic/scroller/Scroller.d.ts +19 -0
  16. package/build/types/desktop/cmp/tab/dynamic/scroller/ScrollerModel.d.ts +23 -0
  17. package/build/types/desktop/cmp/tab/impl/Tab.d.ts +7 -2
  18. package/build/types/desktop/cmp/tab/impl/TabContainer.d.ts +1 -1
  19. package/build/types/desktop/cmp/tab/impl/TabContextMenuItems.d.ts +4 -0
  20. package/build/types/desktop/cmp/tab/index.d.ts +1 -0
  21. package/build/types/dynamics/desktop.d.ts +1 -0
  22. package/build/types/mobile/cmp/tab/impl/TabContainer.d.ts +1 -1
  23. package/cmp/tab/TabContainer.ts +19 -4
  24. package/cmp/tab/TabContainerModel.ts +113 -54
  25. package/cmp/tab/TabModel.ts +1 -2
  26. package/cmp/tab/Types.ts +80 -0
  27. package/cmp/tab/index.ts +1 -1
  28. package/data/RecordAction.ts +4 -1
  29. package/desktop/appcontainer/AppContainer.ts +3 -2
  30. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilter.ts +1 -1
  31. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +0 -1
  32. package/desktop/cmp/tab/TabSwitcher.ts +18 -3
  33. package/desktop/cmp/tab/Tabs.scss +1 -0
  34. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss +53 -0
  35. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +237 -0
  36. package/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts +167 -0
  37. package/desktop/cmp/tab/dynamic/scroller/Scroller.ts +69 -0
  38. package/desktop/cmp/tab/dynamic/scroller/ScrollerModel.ts +92 -0
  39. package/desktop/cmp/tab/impl/Tab.ts +30 -6
  40. package/desktop/cmp/tab/impl/TabContainer.ts +34 -9
  41. package/desktop/cmp/tab/impl/TabContextMenuItems.ts +21 -0
  42. package/desktop/cmp/tab/index.ts +1 -0
  43. package/dynamics/desktop.ts +2 -0
  44. package/mobile/cmp/tab/impl/TabContainer.ts +16 -9
  45. package/package.json +1 -1
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/build/types/cmp/tab/TabSwitcherProps.d.ts +0 -16
  48. 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 {hoistCmp, refreshContextView, uses} from '@xh/hoist/core';
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
- export const tab = hoistCmp.factory({
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, {flex: 1}))
72
+ item: errorBoundary(elementFromContent(content, contentProps))
49
73
  })
50
74
  });
51
75
  }