Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new DynamicTabSwitcher component #3671

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 65.0.0-SNAPSHOT - unreleased

### 🎁 New Features
* Added new `DynamicTabSwitcher` component, a more user-customizable version of `TabSwitcher` that
allows for dynamic addition, removal, and drag-and-drop reordering of tabs with the ability to
persist tab state across sessions.

## 64.0.2 - 2024-05-23

### ⚙️ Technical
Expand Down
34 changes: 34 additions & 0 deletions desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.xh-dynamic-tab-switcher {
&__tabs {
display: flex;
flex-direction: row;
overflow-x: auto;

&::-webkit-scrollbar {
display: none;
}

&__tab {
&:not(&--active) {
cursor: pointer;
}

&--dragging {
background-color: var(--xh-menu-item-highlight-bg);
}

&__close-button {
align-self: center;
padding: 0 !important;
margin-left: 3px;
min-height: 15px;
min-width: 15px;
border-radius: 100% !important;
}

&:not(&:hover):not(&--dragging) &__close-button {
visibility: hidden;
}
}
}
}
155 changes: 155 additions & 0 deletions desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import composeRefs from '@seznam/compose-react-refs';
import {div, hframe, span} from '@xh/hoist/cmp/layout';
import {HoistProps, hoistCmp, uses, useContextModel} from '@xh/hoist/core';
import {button} from '@xh/hoist/desktop/cmp/button';
import {contextMenu} from '@xh/hoist/desktop/cmp/contextmenu';
import {hScroller} from '@xh/hoist/desktop/cmp/tab/dynamic/hscroller/HScroller';
import {HScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/hscroller/HScrollerModel';
import {popover} from '@xh/hoist/kit/blueprint';
import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
import {consumeEvent} from '@xh/hoist/utils/js';
import classNames from 'classnames';
import {first, isEmpty} from 'lodash';
import {CSSProperties, Ref, useEffect, useRef} from 'react';
import {DynamicTabSwitcherModel} from './DynamicTabSwitcherModel';
import {Icon} from '@xh/hoist/icon';
import {TabModel} from '@xh/hoist/cmp/tab';
import {wait} from '@xh/hoist/promise';
import './DynamicTabSwitcher.scss';

/**
* A tab switcher that displays tabs as draggable items in a horizontal list.
* Tabs can be added, removed, and reordered. All actions can be persisted.
*/

export const [DynamicTabSwitcher, dynamicTabSwitcher] = hoistCmp.withFactory({
className: 'xh-dynamic-tab-switcher',
displayName: 'DynamicTabSwitcher',
model: uses(DynamicTabSwitcherModel),
render({className}) {
return hframe({
className,
items: [hScroller({content: tabs}), addButton()]
});
}
});

interface TabsProps extends HoistProps<DynamicTabSwitcherModel> {
ref: Ref<HTMLDivElement>;
}

const tabs = hoistCmp.factory<TabsProps>(({model}, ref) => {
const {visibleTabs} = model;
return dragDropContext({
onDragEnd: result => model.onDragEnd(result),
item: droppable({
droppableId: model.xhId,
direction: 'horizontal',
item: provided =>
div({
className: 'xh-dynamic-tab-switcher__tabs xh-tab-switcher xh-tab-switcher--top',
ref: composeRefs(provided.innerRef, ref),
item: div({
className: 'bp5-tabs',
item: div({
className: 'bp5-tab-list',
items: [
visibleTabs.map((tabModel, index) =>
tab({key: tabModel.id, tabModel, index})
),
provided.placeholder
]
})
})
})
})
});
});

const addButton = hoistCmp.factory<DynamicTabSwitcherModel>(({model}) => {
const {hiddenTabs} = model;
if (isEmpty(hiddenTabs)) return null;
return popover({
interactionKind: 'click',
content: contextMenu({
menuItems: [...model.hiddenTabActions(), '-', model.resetDefaultAction()]
}),
item: button({icon: Icon.add()})
});
});

interface TabProps extends HoistProps<DynamicTabSwitcherModel> {
tabModel: TabModel;
index: number;
}

const tab = hoistCmp.factory<TabProps>(({tabModel, index, model}) => {
const isActive = model.activeTab === tabModel,
isCloseable = model.visibleTabs.length > 1,
tabRef = useRef<HTMLDivElement>(),
scrollerModel = useContextModel(HScrollerModel),
{showScrollButtons} = scrollerModel;

// Handle this at the component level rather than in the model since they are not "linked"
useEffect(() => {
if (isActive && showScrollButtons) {
// Wait a tick for scroll buttons to render, then scroll to the active tab
wait().then(() => tabRef.current.scrollIntoView({behavior: 'smooth'}));
}
}, [isActive, showScrollButtons]);

return draggable({
key: tabModel.id,
draggableId: tabModel.id,
index,
item: (provided, snapshot) =>
hframe({
className: classNames(
'xh-dynamic-tab-switcher__tabs__tab',
isActive && 'xh-dynamic-tab-switcher__tabs__tab--active',
snapshot.isDragging && 'xh-dynamic-tab-switcher__tabs__tab--dragging'
),
onClick: () => model.activate(tabModel),
onContextMenu: e => model.onContextMenu(e, tabModel),
ref: composeRefs(provided.innerRef, tabRef),
...provided.draggableProps,
...provided.dragHandleProps,
style: getStyles(provided.draggableProps.style),
items: [
div({
'aria-selected': isActive,
className: 'bp5-tab',
item: span({
className: 'bp5-popover-target',
item: hframe({
className: 'xh-tab-switcher__tab',
tabIndex: -1,
item: tabModel.title
})
})
}),
button({
className: 'xh-dynamic-tab-switcher__tabs__tab__close-button',
icon: Icon.x(),
minimal: true,
onClick: e => {
consumeEvent(e);
model.hide(tabModel.id);
},
omit: !isCloseable
})
]
})
});
});

const getStyles = (style: CSSProperties): CSSProperties => {
const {transform} = style;
if (!transform) return style;

return {
...style,
// Only drag horizontally
transform: `${first(transform.split(','))}, 0)`
};
};
Loading
Loading