diff --git a/CHANGELOG.md b/CHANGELOG.md index 232da80ef6..0ef28a8420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss new file mode 100644 index 0000000000..0591991690 --- /dev/null +++ b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.scss @@ -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; + } + } + } +} diff --git a/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts new file mode 100644 index 0000000000..a8ff8ea37e --- /dev/null +++ b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts @@ -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 { + ref: Ref; +} + +const tabs = hoistCmp.factory(({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(({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 { + tabModel: TabModel; + index: number; +} + +const tab = hoistCmp.factory(({tabModel, index, model}) => { + const isActive = model.activeTab === tabModel, + isCloseable = model.visibleTabs.length > 1, + tabRef = useRef(), + 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)` + }; +}; diff --git a/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts b/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts new file mode 100644 index 0000000000..5e98cbefff --- /dev/null +++ b/desktop/cmp/tab/dynamic/DynamicTabSwitcherModel.ts @@ -0,0 +1,203 @@ +import {TabContainerModel, TabModel} from '@xh/hoist/cmp/tab'; +import { + HoistModel, + managed, + MenuItemLike, + NonEmptyArray, + PersistenceProvider, + PersistOptions, + ReactionSpec, + XH +} from '@xh/hoist/core'; +import {contextMenu} from '@xh/hoist/desktop/cmp/contextmenu'; +import {Icon} from '@xh/hoist/icon'; +import {showContextMenu} from '@xh/hoist/kit/blueprint'; +import {makeObservable} from '@xh/hoist/mobx'; +import {compact, first, isEmpty, isEqual, without} from 'lodash'; +import {action, computed, observable, runInAction, when} from 'mobx'; +import React from 'react'; + +export interface DynamicTabSwitcherConfig { + /** IDs of tabs to show by default (in order) or `null` (default) to show all tabs. */ + defaultTabIds?: NonEmptyArray | null; + /** Options governing persistence. */ + persistWith?: PersistOptions; + /** TabContainerModel to which this switcher should bind. */ + tabContainerModel: TabContainerModel; +} + +export class DynamicTabSwitcherModel extends HoistModel { + declare config: DynamicTabSwitcherConfig; + + @managed provider: PersistenceProvider; + + @observable.ref visibleTabIds: string[]; + + private readonly defaultTabIds: string[]; + private readonly tabContainerModel: TabContainerModel; + + get activeTab(): TabModel { + return this.tabContainerModel.activeTab; + } + + @computed + get visibleTabs(): TabModel[] { + return compact(this.visibleTabIds.map(id => this.tabContainerModel.findTab(id))); + } + + @computed + get hiddenTabs(): TabModel[] { + const visibleTabIds = new Set(this.visibleTabIds); + return this.tabContainerModel.tabs.filter(tab => !visibleTabIds.has(tab.id)); + } + + constructor({ + defaultTabIds = null, + persistWith = null, + tabContainerModel + }: DynamicTabSwitcherConfig) { + super(); + makeObservable(this); + + this.tabContainerModel = tabContainerModel; + + const validDefaultIds = defaultTabIds && this.getValidTabIds(defaultTabIds); + this.visibleTabIds = this.defaultTabIds = isEmpty(validDefaultIds) + ? tabContainerModel.tabs.map(tab => tab.id) + : validDefaultIds; + + if (persistWith) this.setupStateProvider(persistWith); + + // Wait for router to start before observing active tab + when( + () => XH.appIsRunning, + () => this.addReaction(this.activeTabReaction()) + ); + } + + activate(tab: TabModel) { + this.tabContainerModel.activateTab(tab); + } + + @action + hide(tabId: string) { + this.visibleTabIds = without(this.visibleTabIds, tabId); + const {tabContainerModel, visibleTabIds} = this; + if (tabContainerModel.activeTabId === tabId) + tabContainerModel.activateTab(first(visibleTabIds)); + } + + onContextMenu(e: React.MouseEvent, tab: TabModel) { + showContextMenu( + contextMenu({ + menuItems: [ + { + icon: Icon.x(), + text: 'Remove', + actionFn: () => this.hide(tab.id), + prepareFn: me => { + // Don't allow closing the last tab + me.disabled = this.visibleTabs.length === 1; + } + }, + { + icon: Icon.add(), + text: 'Add', + items: this.hiddenTabActions(), + prepareFn: me => { + me.hidden = isEmpty(me.items); + } + }, + '-', + this.resetDefaultAction() + ] + }), + { + left: e.clientX, + top: e.clientY + } + ); + } + + @action + onDragEnd(result) { + if (!result.destination) return; + const tabIds = this.visibleTabs.map(tab => tab.id), + [removed] = tabIds.splice(result.source.index, 1); + tabIds.splice(result.destination.index, 0, removed); + this.visibleTabIds = tabIds; + } + + hiddenTabActions(): MenuItemLike[] { + return this.hiddenTabs.map(tab => ({ + text: tab.title, + actionFn: () => this.activate(tab) + })); + } + + resetDefaultAction(): MenuItemLike { + return { + icon: Icon.reset(), + text: 'Restore Default Tabs', + intent: 'warning', + actionFn: () => + runInAction(() => { + const {activeTab, defaultTabIds, tabContainerModel} = this; + this.visibleTabIds = defaultTabIds; + if (!defaultTabIds.includes(activeTab.id)) { + tabContainerModel.activateTab(first(defaultTabIds)); + } + }), + prepareFn: me => { + me.disabled = isEqual(this.visibleTabIds, this.defaultTabIds); + } + }; + } + + // ------------------------------- + // Implementation + // ------------------------------- + + private activeTabReaction(): ReactionSpec { + return { + track: () => this.tabContainerModel.activeTabId, + run: tabId => { + if (!this.visibleTabIds.includes(tabId)) + this.visibleTabIds = [...this.visibleTabIds, tabId]; + }, + fireImmediately: true + }; + } + + private setupStateProvider(persistWith: PersistOptions) { + // Read state from provider -- fail gently + let visibleTabIds: string[] = this.visibleTabIds; + try { + this.provider = PersistenceProvider.create({ + path: 'dynamicTabSwitcher', + ...persistWith + }); + const state = this.provider.read(); + visibleTabIds = state?.visibleTabIds ?? visibleTabIds; + } catch (e) { + this.logError(e); + XH.safeDestroy(this.provider); + this.provider = null; + } + + // Initialize state. + this.visibleTabIds = this.getValidTabIds(visibleTabIds); + + // Attach to provider last + if (this.provider) { + this.addReaction({ + track: () => this.visibleTabIds, + run: visibleTabIds => this.provider.write({visibleTabIds}) + }); + } + } + + private getValidTabIds(tabIds: string[]): string[] { + return tabIds.filter(id => this.tabContainerModel.findTab(id)); + } +} diff --git a/desktop/cmp/tab/dynamic/hscroller/HScroller.ts b/desktop/cmp/tab/dynamic/hscroller/HScroller.ts new file mode 100644 index 0000000000..04403da718 --- /dev/null +++ b/desktop/cmp/tab/dynamic/hscroller/HScroller.ts @@ -0,0 +1,51 @@ +import composeRefs from '@seznam/compose-react-refs'; +import {hbox} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, HoistProps} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {HScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/hscroller/HScrollerModel'; +import {Icon} from '@xh/hoist/icon'; +import {useOnResize} from '@xh/hoist/utils/react'; +import React, {Ref} from 'react'; + +/** + * A horizontal scroller component that displays a content component with left and right scroll + * buttons when the content overflows the viewport. + */ + +export interface HScrollerProps extends HoistProps { + /** The content to be displayed within the scroller. */ + content: React.FC<{ref: Ref}>; +} + +export const [HScroller, hScroller] = hoistCmp.withFactory({ + displayName: 'HScroller', + model: creates(HScrollerModel, {publishMode: 'limited'}), + render({content, model}) { + const {contentRef} = model; + return hbox( + scrollButton({direction: 'left', model}), + content({ + ref: composeRefs( + contentRef, + useOnResize(() => model.onViewportEvent()) + ) + }), + scrollButton({direction: 'right', model}) + ); + } +}); + +interface ScrollButtonProps extends HoistProps { + direction: 'left' | 'right'; +} + +const scrollButton = hoistCmp.factory(({direction, model}) => { + if (!model.showScrollButtons) return null; + return button({ + icon: direction === 'left' ? Icon.chevronLeft() : Icon.chevronRight(), + disabled: direction === 'left' ? model.isScrolledToLeft : model.isScrolledToRight, + onMouseDown: () => model.scroll(direction), + onMouseUp: () => model.stopScrolling(), + onMouseLeave: () => model.stopScrolling() + }); +}); diff --git a/desktop/cmp/tab/dynamic/hscroller/HScrollerModel.ts b/desktop/cmp/tab/dynamic/hscroller/HScrollerModel.ts new file mode 100644 index 0000000000..0104aa8019 --- /dev/null +++ b/desktop/cmp/tab/dynamic/hscroller/HScrollerModel.ts @@ -0,0 +1,82 @@ +import {HoistModel} from '@xh/hoist/core'; +import {makeObservable} from '@xh/hoist/mobx'; +import {isNil} from 'lodash'; +import {action, computed, observable} from 'mobx'; +import {createRef} from 'react'; + +/** + * Internal model for the HScroller component. Used to manage the scroll state and provide + * scroll functionality. Uses animation frames to ensure smooth scrolling. + */ + +export class HScrollerModel extends HoistModel { + contentRef = createRef(); + + @observable private scrollLeft: number; + @observable private scrollWidth: number; + @observable private clientWidth: number; + + private animationFrameId: number; + + @computed + get showScrollButtons(): boolean { + return this.scrollWidth > this.clientWidth; + } + + @computed + get isScrolledToLeft(): boolean { + return this.scrollLeft === 0; + } + + @computed + get isScrolledToRight(): boolean { + // Allow for a 1px buffer to account for rounding errors discovered when testing + return this.scrollLeft + this.clientWidth >= this.scrollWidth - 1; + } + + constructor() { + super(); + makeObservable(this); + } + + override afterLinked() { + this.contentRef.current.addEventListener('scroll', () => this.onViewportEvent()); + } + + scroll(direction: 'left' | 'right') { + this.stopScrolling(); + this.animationFrameId = window.requestAnimationFrame(() => { + const {current} = this.contentRef; + if ( + !current || + (direction === 'left' && this.isScrolledToLeft) || + (direction === 'right' && this.isScrolledToRight) + ) { + this.stopScrolling(); + return; + } + current.scrollLeft += direction === 'left' ? -10 : 10; + this.scroll(direction); + }); + } + + stopScrolling() { + if (!isNil(this.animationFrameId)) { + window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + @action + onViewportEvent() { + const {scrollLeft, scrollWidth, clientWidth} = this.contentRef.current; + this.scrollLeft = scrollLeft; + this.scrollWidth = scrollWidth; + this.clientWidth = clientWidth; + } + + override destroy() { + this.stopScrolling(); + super.destroy(); + } +} diff --git a/desktop/cmp/tab/index.ts b/desktop/cmp/tab/index.ts index c6c7d2f02e..4a6f7ad7dc 100644 --- a/desktop/cmp/tab/index.ts +++ b/desktop/cmp/tab/index.ts @@ -5,3 +5,4 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ export * from './TabSwitcher'; +export * from './dynamic/DynamicTabSwitcher';