Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions client-app/src/apps/dynamicTabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import '../Bootstrap';

import {XH} from '@xh/hoist/core';
import {AppContainer} from '@xh/hoist/desktop/appcontainer';
import {AppComponent} from '../examples/dynamicTabs/AppComponent';
import {AppModel} from '../examples/dynamicTabs/AppModel';
import {AuthModel} from '../core/AuthModel';

XH.renderApp({
clientAppCode: 'dynamicTabs',
clientAppName: 'Dynamic Routable Tabs',
componentClass: AppComponent,
modelClass: AppModel,
containerClass: AppContainer,
authModelClass: AuthModel,
isMobileApp: false,
enableLogout: true,
checkAccess: () => true
});
22 changes: 22 additions & 0 deletions client-app/src/examples/dynamicTabs/AppComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {hoistCmp, uses} from '@xh/hoist/core';
import {appBar} from '@xh/hoist/desktop/cmp/appbar';
import {panel} from '@xh/hoist/desktop/cmp/panel';
import {Icon} from '@xh/hoist/icon';
import {AppModel} from './AppModel';
import {dynamicRoutableTabsPanel} from './DynamicRoutableTabsPanel';
import '../../core/Toolbox.scss';

export const AppComponent = hoistCmp({
displayName: 'App',
model: uses(AppModel),

render() {
return panel({
tbar: appBar({
icon: Icon.tab({size: '2x', prefix: 'fal'}),
appMenuButtonProps: {hideLogoutItem: false}
}),
item: dynamicRoutableTabsPanel()
});
}
});
20 changes: 20 additions & 0 deletions client-app/src/examples/dynamicTabs/AppModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {BaseAppModel} from '../../BaseAppModel';

export class AppModel extends BaseAppModel {
static instance: AppModel;

override getRoutes() {
return [
{
name: 'default',
path: '/dynamicTabs',
children: [
{name: 'home', path: '/home'},
{name: 'settings', path: '/settings'},
{name: 'about', path: '/about'},
{name: 'item', path: '/item/:id'}
]
}
];
}
}
179 changes: 179 additions & 0 deletions client-app/src/examples/dynamicTabs/DynamicRoutableTabsModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import {TabContainerModel} from '@xh/hoist/cmp/tab';
import {HoistModel, managed, XH} from '@xh/hoist/core';
import {Icon} from '@xh/hoist/icon';
import {action, bindable, makeObservable} from '@xh/hoist/mobx';
import {wait} from '@xh/hoist/promise';
import {homePanel} from './tabs/HomePanel';
import {settingsPanel} from './tabs/SettingsPanel';
import {aboutPanel} from './tabs/AboutPanel';
import {itemDetailPanel} from './tabs/ItemDetailPanel';

/**
* Model demonstrating dynamic, routable tabs.
*
* Cannot use TabContainerModel's built-in `route` config because it prohibits all dynamic
* tab mutations (addTab/removeTab both call setTabs, which throws when routing is enabled).
* Instead, we manage routing manually with bidirectional reactions.
*/
export class DynamicRoutableTabsModel extends HoistModel {
@managed
tabContainerModel: TabContainerModel = new TabContainerModel({
// No `route` config — we manage routing manually.
switcher: {mode: 'dynamic', initialFavorites: ['home', 'settings', 'about']},
tabs: [
{id: 'home', title: 'Home', icon: Icon.home(), content: homePanel},
{id: 'settings', title: 'Settings', icon: Icon.gear(), content: settingsPanel},
{id: 'about', title: 'About', icon: Icon.info(), content: aboutPanel}
]
});

/** ID typed into the "open item" input. */
@bindable itemIdInput: string = '';

// Guard to prevent reaction loops during sync.
private _syncing = false;
private _routerUnsub = null;

constructor() {
super();
makeObservable(this);
}

override onLinked() {
super.onLinked();

// Router → Tabs: subscribe directly to Router 5 for reliable change detection,
// including param-only changes on the same route name. MobX reactions on
// XH.routerState don't reliably detect these.
this._routerUnsub = XH.router.subscribe(() => this.syncRouterToTabs());
// Initial sync on mount.
this.syncRouterToTabs();

// Tabs → Router: when the active tab changes, update the URL.
this.addReaction({
track: () => this.tabContainerModel.activeTabId,
run: () => this.syncTabsToRouter(),
fireImmediately: true
});

// Cleanup: when the DynamicTabSwitcher hides an item tab (user clicks X), defer
// removal to the next tick so that MobX reactions from tab creation have settled.
this.addReaction({
track: () =>
this.tabContainerModel.dynamicTabSwitcherModel?.visibleTabs?.map(t => t.id),
run: visibleIds => this.deferredCleanupHiddenItemTabs(visibleIds)
});
}

/** Open (or activate) the item tab for the given ID. */
@action
openItemTab(id: string | number) {
const itemId = String(id),
tabId = `item-${itemId}`,
{tabContainerModel} = this;

if (!tabContainerModel.findTab(tabId)) {
tabContainerModel.addTab({
id: tabId,
title: `Item ${itemId}`,
icon: Icon.detail(),
content: () => itemDetailPanel({itemId})
});
}

tabContainerModel.activateTab(tabId);
}

/** Handle "Open Tab" button click. */
@action
onOpenItemClick() {
const id = this.itemIdInput?.trim();
if (id) {
this.openItemTab(id);
this.itemIdInput = '';
}
}

override destroy() {
if (typeof this._routerUnsub === 'function') this._routerUnsub();
super.destroy();
}

//------------------------------------------------------------------
// Routing sync
//------------------------------------------------------------------
private syncRouterToTabs() {
if (this._syncing) return;
this._syncing = true;
try {
const state = XH.routerState;
if (!state) return;

const {name, params} = state;

// Match parameterized item route.
// Note: cannot use router.isActive('default.item') without params — Router 5
// requires params to match for parameterized routes. Check route name directly.
if (name === 'default.item' && params?.id) {
this.openItemTab(params.id);
return;
}

// Match static tab routes by name.
const staticIds = ['home', 'settings', 'about'];
for (const tabId of staticIds) {
if (name === 'default.' + tabId) {
this.tabContainerModel.activateTab(tabId);
return;
}
}

// Fallback: if we're on the base route with no child, go to home.
if (name === 'default') {
this.tabContainerModel.activateTab('home');
}
} finally {
this._syncing = false;
}
}

private syncTabsToRouter() {
if (this._syncing) return;
this._syncing = true;
try {
const tabId = this.tabContainerModel.activeTabId;
if (!tabId) return;

if (tabId.startsWith('item-')) {
const itemId = tabId.replace('item-', '');
XH.navigate('default.item', {id: itemId});
} else {
XH.navigate('default.' + tabId);
}
} finally {
this._syncing = false;
}
}

/**
* When the DynamicTabSwitcher "hides" a tab (user clicks X), the tab is removed from the
* switcher's visible list but still exists in the container. For dynamic item tabs, we want
* to fully remove them. Deferred to next tick to avoid racing with tab creation — the
* DynamicTabSwitcherModel's activeTabReaction needs time to add newly activated tabs to
* its visibleTabs before we check for orphans.
*/
private deferredCleanupHiddenItemTabs(visibleIds: string[]) {
if (!visibleIds) return;
wait().then(() => {
if (this.isDestroyed) return;
const switcherVisibleIds =
this.tabContainerModel.dynamicTabSwitcherModel?.visibleTabs?.map(t => t.id) ?? [];
const allTabs = [...this.tabContainerModel.tabs];
for (const tab of allTabs) {
if (tab.id.startsWith('item-') && !switcherVisibleIds.includes(tab.id)) {
this.tabContainerModel.removeTab(tab);
}
}
});
}
}
41 changes: 41 additions & 0 deletions client-app/src/examples/dynamicTabs/DynamicRoutableTabsPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {tabContainer} from '@xh/hoist/cmp/tab';
import {creates, hoistCmp} from '@xh/hoist/core';
import {button} from '@xh/hoist/desktop/cmp/button';
import {textInput} from '@xh/hoist/desktop/cmp/input';
import {panel} from '@xh/hoist/desktop/cmp/panel';
import {dynamicTabSwitcher} from '@xh/hoist/desktop/cmp/tab';
import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
import {filler, span} from '@xh/hoist/cmp/layout';
import {Icon} from '@xh/hoist/icon';
import {DynamicRoutableTabsModel} from './DynamicRoutableTabsModel';

export const dynamicRoutableTabsPanel = hoistCmp.factory({
model: creates(DynamicRoutableTabsModel),

render({model}) {
const {tabContainerModel} = model;
return panel({
tbar: toolbar(
dynamicTabSwitcher({model: tabContainerModel, flex: 1}),
filler(),
span('Open Item:'),
textInput({
bind: 'itemIdInput',
model,
width: 80,
placeholder: 'ID...',
commitOnChange: true,
onKeyDown: e => {
if (e.key === 'Enter') model.onOpenItemClick();
}
}),
button({
text: 'Open Tab',
icon: Icon.add(),
onClick: () => model.onOpenItemClick()
})
),
item: tabContainer({model: tabContainerModel, switcher: false})
});
}
});
Loading
Loading