From d16aa373be5826b631642fb04536fe67e475059a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Thu, 17 Sep 2026 20:39:50 +0100 Subject: [PATCH 01/41] Implement Navigation Tabs and Dashboard Enhancements - Introduced NavigationTabsContext to manage tab state and navigation within the dashboard. - Added DashboardTabs component for improved tabbed navigation, allowing users to add, close, and reorder tabs. - Enhanced Dashboard layout to conditionally render tab panes based on the active tab, improving user experience. - Updated various components to utilize navigation tab context, ensuring consistent title management and tab interactions. - Implemented drag-and-drop functionality for tabs, enabling users to rearrange their workspace effectively. - Refactored CSS styles for dashboard tabs and panes to ensure proper layout and responsiveness across devices. --- assets/stylesheets/App.css | 73 ++ src/App.jsx | 3 + src/bun/index.js | 6 +- src/components/Dashboard/Dashboard.jsx | 11 +- src/components/Dashboard/Layout.jsx | 2 +- src/components/Dashboard/Management/About.jsx | 2 + .../Dashboard/Management/Notes/NoteInfo.jsx | 2 + .../Dashboard/Management/Settings.jsx | 2 + .../Dashboard/common/DashboardBreadcrumb.jsx | 9 +- .../Dashboard/common/DashboardChildRoutes.jsx | 21 + .../Dashboard/common/DashboardNavigation.jsx | 32 +- .../common/DashboardOverviewPage.jsx | 5 + .../Dashboard/common/DashboardTabPanes.jsx | 126 ++++ .../Dashboard/common/DashboardTabs.jsx | 139 ++++ .../common/DashboardWindowButtons.jsx | 7 +- src/components/Dashboard/common/ModelPage.jsx | 14 +- .../Dashboard/common/SegmentedNav.jsx | 94 ++- .../Dashboard/common/WindowAppMenu.jsx | 29 +- src/components/Dashboard/common/tabDrag.js | 59 ++ .../context/DashboardObjectToolsContext.jsx | 5 +- .../Dashboard/context/ElectronContext.jsx | 100 ++- .../context/NavigationTabsContext.jsx | 528 +++++++++++++++ .../context/ObjectListViewContext.jsx | 7 + src/database/Settings.js | 14 +- src/desktop/menu.js | 27 +- src/desktop/notify.js | 34 +- src/desktop/rpc.js | 61 +- src/desktop/session.js | 70 ++ src/desktop/window.js | 637 ++++++++++++++---- src/electrobun-bridge.js | 29 +- 30 files changed, 1963 insertions(+), 185 deletions(-) create mode 100644 src/components/Dashboard/common/DashboardChildRoutes.jsx create mode 100644 src/components/Dashboard/common/DashboardTabPanes.jsx create mode 100644 src/components/Dashboard/common/DashboardTabs.jsx create mode 100644 src/components/Dashboard/common/tabDrag.js create mode 100644 src/components/Dashboard/context/NavigationTabsContext.jsx create mode 100644 src/desktop/session.js diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 833e47f3..a01191ad 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -3346,6 +3346,79 @@ body.objectKanbanColumnResizing * { min-width: 20px; } +.dashboard-tabs { + flex: 1; + min-width: 0; +} + +.dashboard-tabs .segmented-nav-item-label { + padding-inline: 0px; + padding-left: 8px; +} + +.dashboard-tabs-segmented-wrap { + flex: 1; + min-width: 0; +} + +.dashboard-tabs-segmented-wrap .simplebar-track.simplebar-vertical { + display: none; +} + +.dashboard-tabs-segmented-inner { + width: max-content; + align-items: center; +} + +.dashboard-tabs-label { + max-width: 200px; + margin-top: -2px; +} + +.dashboard-tabs-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: 2px; + border-radius: 8px; + color: var(--color-text-secondary, inherit); + opacity: 0.7; +} + +.dashboard-tabs-close:hover { + opacity: 1; + background: var(--color-button-background); +} + +.dashboard-tabs-add-button { + padding-inline: 4px; + min-width: 20px; + margin-bottom: 2px; +} + +.dashboard-tab-panes { + position: relative; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +.dashboard-tab-pane { + height: 100%; + min-height: 0; +} + +.dashboard-tab-pane-active { + display: flex; + flex-direction: column; +} + +.dashboard-tab-pane-inactive { + display: none; +} + .state-tag-loading svg path { stroke: currentColor; stroke-width: 12px; diff --git a/src/App.jsx b/src/App.jsx index abfdb534..d84eec42 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ import AppError from './components/App/AppError' import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx' import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx' import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx' +import { NavigationTabsProvider } from './components/Dashboard/context/NavigationTabsContext.jsx' import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx' import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx' import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx' @@ -84,6 +85,7 @@ const AppContent = () => { + @@ -183,6 +185,7 @@ const AppContent = () => { + diff --git a/src/bun/index.js b/src/bun/index.js index 847d1499..75d0aa73 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -22,22 +22,22 @@ const { const { createMainWindow, handleDeepLinkFromArgv, + persistSessionNow, setupDevAuthServer, - setupNavigationGestures, setupWindowsDeepLinkHandling } = await import('../desktop/window.js') setupWindowsDeepLinkHandling() const rpc = createAppRpc() -const mainWindow = await createMainWindow(rpc) +await createMainWindow(rpc) -setupNavigationGestures(mainWindow) registerGlobalShortcuts(rpc) setupDevAuthServer() handleDeepLinkFromArgv(launchUrl) process.on('exit', () => { + persistSessionNow() unregisterGlobalShortcuts() closeSingleInstanceServer() }) diff --git a/src/components/Dashboard/Dashboard.jsx b/src/components/Dashboard/Dashboard.jsx index cd85661d..30556356 100644 --- a/src/components/Dashboard/Dashboard.jsx +++ b/src/components/Dashboard/Dashboard.jsx @@ -1,13 +1,12 @@ -// Dashboard.js import Layout from './Layout' import { Outlet } from 'react-router-dom' +import { useNavigationTabs } from './context/NavigationTabsContext' +import DashboardTabPanes from './common/DashboardTabPanes' const Dashboard = () => { - return ( - - - - ) + const { isElectron } = useNavigationTabs() + + return {isElectron ? : } } export default Dashboard diff --git a/src/components/Dashboard/Layout.jsx b/src/components/Dashboard/Layout.jsx index 4516ab36..5373f6ff 100644 --- a/src/components/Dashboard/Layout.jsx +++ b/src/components/Dashboard/Layout.jsx @@ -66,7 +66,7 @@ const DashboardLayout = ({ children }) => { {currentObjectTools} - {children} +
{children}
diff --git a/src/components/Dashboard/Management/About.jsx b/src/components/Dashboard/Management/About.jsx index a2ca7d53..da6c0d60 100644 --- a/src/components/Dashboard/Management/About.jsx +++ b/src/components/Dashboard/Management/About.jsx @@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive' import FarmControlAppIcon from '../../Logos/FarmControlAppIcon' import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon' import ExternalLink from '../common/ExternalLink' +import { useNavigationTabPage } from '../context/NavigationTabsContext' const { Title, Text } = Typography const About = () => { + useNavigationTabPage({ title: 'About' }) const [collapseState, updateCollapseState] = useCollapseState('About', { updater: true }) diff --git a/src/components/Dashboard/Management/Notes/NoteInfo.jsx b/src/components/Dashboard/Management/Notes/NoteInfo.jsx index ef653a79..52dce1da 100644 --- a/src/components/Dashboard/Management/Notes/NoteInfo.jsx +++ b/src/components/Dashboard/Management/Notes/NoteInfo.jsx @@ -21,6 +21,7 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx' import ActionHandler from '../../common/ActionHandler.jsx' import ObjectActions from '../../common/ObjectActions.jsx' import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext' +import { useNavigationTabPage } from '../../context/NavigationTabsContext' import ObjectTable from '../../common/ObjectTable.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx' @@ -32,6 +33,7 @@ const log = loglevel.getLogger('NoteInfo') log.setLevel(config.logLevel) const NoteInfo = () => { + useNavigationTabPage({ title: 'Info - Note', modelName: 'note' }) const location = useLocation() const objectFormRef = useRef(null) const actionHandlerRef = useRef(null) diff --git a/src/components/Dashboard/Management/Settings.jsx b/src/components/Dashboard/Management/Settings.jsx index 4b594525..faa7e6bd 100644 --- a/src/components/Dashboard/Management/Settings.jsx +++ b/src/components/Dashboard/Management/Settings.jsx @@ -23,6 +23,7 @@ import settingsSchema, { getVisibleSettingsSections, pickSettings } from '../../../database/Settings' +import { useNavigationTabPage } from '../context/NavigationTabsContext' const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_ENGINE = 'native' @@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [ ] const Settings = () => { + useNavigationTabPage({ title: 'Settings' }) const { isDarkMode, isCompact, diff --git a/src/components/Dashboard/common/DashboardBreadcrumb.jsx b/src/components/Dashboard/common/DashboardBreadcrumb.jsx index c9e5f393..fc7a5845 100644 --- a/src/components/Dashboard/common/DashboardBreadcrumb.jsx +++ b/src/components/Dashboard/common/DashboardBreadcrumb.jsx @@ -1,11 +1,14 @@ // DashboardBreadcrumb.js +import { useContext } from 'react' import { Breadcrumb, Button, Flex, Space } from 'antd' import { Link, useLocation, useNavigate } from 'react-router-dom' import ArrowLeftIcon from '../../Icons/ArrowLeftIcon' import ArrowRightIcon from '../../Icons/ArrowRightIcon' import FilterIcon from '../../Icons/FilterIcon' import { getModelByPluralName } from '../../../database/ObjectModels' +import { ElectronContext } from '../context/ElectronContext' import { useTableState } from '../context/TableStateContext' +import { useNavigationTabs } from '../context/NavigationTabsContext' const breadcrumbNameMap = { production: 'Production', @@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer'] const DashboardBreadcrumb = () => { const location = useLocation() const navigate = useNavigate() + const { isElectron } = useContext(ElectronContext) + const { goBack, goForward } = useNavigationTabs() const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState() const pathSnippets = location.pathname.split('/').filter((i) => i) @@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => { + + ) + })} + @@ -313,13 +308,15 @@ const DashboardTabs = () => { ) })} - + ) +} + +DashboardTabItem.propTypes = { + tab: PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string, + modelName: PropTypes.string + }).isRequired, + tabIndex: PropTypes.number.isRequired, + isSelected: PropTypes.bool, + isDragging: PropTypes.bool, + dropPosition: PropTypes.oneOf(['before', 'after']), + onDragStart: PropTypes.func.isRequired, + onDragEnd: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, + onKeyDown: PropTypes.func.isRequired, + onDragOver: PropTypes.func.isRequired, + onDrop: PropTypes.func.isRequired, + onClose: PropTypes.func +} + +const STICKY_EDGE_INSET = 20 + +const measureTabMetrics = (container) => { + const button = container.querySelector('.dashboard-tab-item') + const icon = container.querySelector('.dashboard-tab-item-icon') + if (!button || !icon) { + return { + naturalWidth: container.offsetWidth, + minWidth: container.offsetWidth + } + } + + const styles = window.getComputedStyle(button) + const minWidth = Math.ceil( + icon.getBoundingClientRect().width + + parseFloat(styles.paddingLeft) + + parseFloat(styles.paddingRight) + + parseFloat(styles.borderLeftWidth) + + parseFloat(styles.borderRightWidth) + ) + + return { + naturalWidth: container.offsetWidth, + minWidth: Math.max(minWidth, 1) + } +} + +const hideStickyClone = (cloneEl) => { + if (!cloneEl) return + delete cloneEl.dataset.edge + delete cloneEl.dataset.faded + cloneEl.style.removeProperty('--tab-sticky-width') + cloneEl.style.removeProperty('--tab-label-opacity') + cloneEl.style.left = '' + cloneEl.style.right = '' + cloneEl.setAttribute('inert', '') +} + +const setOriginalStickyState = (container, sticky) => { + if (!container) return + if (sticky) { + container.dataset.stickyPlaceholder = 'true' + container.setAttribute('inert', '') + } else { + delete container.dataset.stickyPlaceholder + container.removeAttribute('inert') + } +} + +const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { + const state = { + activeEl: null, + updating: false + } + + const update = () => { + if (state.updating) return + state.updating = true + + try { + const container = list.querySelector( + '.dashboard-tab-item-container-active' + ) + if (state.activeEl && state.activeEl !== container) { + setOriginalStickyState(state.activeEl, false) + } + state.activeEl = container + + if (!container || !cloneEl) { + hideStickyClone(cloneEl) + return + } + + const metrics = measureTabMetrics(container) + const naturalWidth = metrics.naturalWidth + const minWidth = Math.min(metrics.minWidth, naturalWidth) + const maxShrink = Math.max(0, naturalWidth - minWidth) + const scrollLeft = scrollEl.scrollLeft + const viewportWidth = scrollEl.clientWidth + if (viewportWidth <= 0) { + setOriginalStickyState(container, false) + hideStickyClone(cloneEl) + return + } + + const scrollRect = scrollEl.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const naturalLeft = scrollLeft + (containerRect.left - scrollRect.left) + const overflowLeft = scrollLeft + STICKY_EDGE_INSET - naturalLeft + const overflowRight = + naturalLeft + + naturalWidth - + (scrollLeft + viewportWidth - STICKY_EDGE_INSET) + + let edge = null + let overflow = 0 + if (overflowLeft > 0.5 && overflowLeft >= overflowRight) { + edge = 'left' + overflow = overflowLeft + } else if (overflowRight > 0.5) { + edge = 'right' + overflow = overflowRight + } + + if (!edge) { + setOriginalStickyState(container, false) + hideStickyClone(cloneEl) + return + } + + const shrink = Math.min(overflow, maxShrink) + const visualWidth = Math.max(minWidth, naturalWidth - shrink) + const fade = maxShrink === 0 ? 1 : 1 - shrink / maxShrink + + setOriginalStickyState(container, true) + cloneEl.removeAttribute('inert') + cloneEl.dataset.edge = edge + if (fade < 0.25) cloneEl.dataset.faded = 'true' + else delete cloneEl.dataset.faded + + const parentRect = + cloneEl.offsetParent?.getBoundingClientRect() || scrollRect + if (edge === 'left') { + cloneEl.style.left = `${scrollRect.left - parentRect.left + STICKY_EDGE_INSET}px` + cloneEl.style.right = 'auto' + } else { + cloneEl.style.left = 'auto' + cloneEl.style.right = `${parentRect.right - scrollRect.right + STICKY_EDGE_INSET}px` + } + cloneEl.style.setProperty('--tab-sticky-width', `${visualWidth}px`) + cloneEl.style.setProperty('--tab-label-opacity', String(fade)) + } finally { + state.updating = false + } + } + + let frame = 0 + const scheduleUpdate = () => { + if (frame) return + frame = window.requestAnimationFrame(() => { + frame = 0 + update() + }) + } + + update() + scrollEl.addEventListener('scroll', scheduleUpdate, { passive: true }) + window.addEventListener('resize', scheduleUpdate) + + const observer = new ResizeObserver(scheduleUpdate) + observer.observe(scrollEl) + + return () => { + scrollEl.removeEventListener('scroll', scheduleUpdate) + window.removeEventListener('resize', scheduleUpdate) + observer.disconnect() + if (frame) window.cancelAnimationFrame(frame) + setOriginalStickyState(state.activeEl, false) + hideStickyClone(cloneEl) + } +} + +const useActiveTabStickyScroll = (activeTabId, tabs) => { + const rootRef = useRef(null) + const listRef = useRef(null) + const cloneRef = useRef(null) + const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}` + + useLayoutEffect(() => { + const root = rootRef.current + const list = listRef.current + const cloneEl = cloneRef.current + if (!root || !list) return undefined + + let cancelled = false + let teardown = () => {} + let frame = 0 + + const tryBind = () => { + if (cancelled) return + const scrollEl = root.querySelector('.simplebar-content-wrapper') + if (!scrollEl) { + frame = window.requestAnimationFrame(tryBind) + return + } + teardown = bindActiveTabStickyScroll(scrollEl, list, cloneEl) + } + + tryBind() + + return () => { + cancelled = true + if (frame) window.cancelAnimationFrame(frame) + teardown() + } + }, [tabLayoutKey]) + + return { rootRef, listRef, cloneRef } +} + const DashboardTabs = () => { const { tabs, @@ -76,6 +347,10 @@ const DashboardTabs = () => { handleTabDragEnd, handleExternalTabDrop } = useNavigationTabs() + const { rootRef, listRef, cloneRef } = useActiveTabStickyScroll( + activeTabId, + tabs + ) const [draggedValue, setDraggedValue] = useState(null) const [dropTarget, setDropTarget] = useState(null) @@ -232,9 +507,24 @@ const DashboardTabs = () => { } const isReordering = draggedValue != null || dropTarget != null + const activeTab = tabs.find((tab) => tab.id === activeTabId) + const activeTabIndex = activeTab + ? tabs.findIndex((tab) => tab.id === activeTabId) + : 0 + + const tabItemProps = { + onDragStart: handleItemDragStart, + onDragEnd: handleItemDragEnd, + onClick: selectTab, + onKeyDown: handleKeyDown, + onDragOver: handleItemDragOver, + onDrop: handleItemDrop, + onClose: closeTab + } return ( {
{ onDrop={handleListDrop} > {tabs.map((tab, index) => { - const model = tab.modelName ? getModelByName(tab.modelName) : null - const Icon = model?.icon || HomeIcon const isSelected = tab.id === activeTabId const isDragging = draggedValue != null && String(draggedValue) === String(tab.id) @@ -273,37 +562,20 @@ const DashboardTabs = () => { 'dashboard-tab-item-container-active': isSelected })} > - +
) })} @@ -319,6 +591,23 @@ const DashboardTabs = () => {
+ {activeTab ? ( +
+ +
+ ) : null}
) } From 9ca872e589456c4c536a5ad7c96f7efc3a2fd710 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 18 Sep 2026 04:31:13 +0100 Subject: [PATCH 13/41] Enhance Dashboard Tabs with Sticky Visuals and Improved Masking - Added CSS styles for sticky tab behavior, including new masking effects for better visual integration during scrolling. - Updated JavaScript logic to manage sticky visuals and tab metrics, improving user interaction and responsiveness. - Adjusted padding and layout properties for dashboard tab items to enhance overall aesthetics and usability. --- assets/stylesheets/App.css | 75 +++++++++++- .../Dashboard/common/DashboardTabs.jsx | 112 +++++++++++++++--- 2 files changed, 169 insertions(+), 18 deletions(-) diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index bd5f5d9f..45d083ae 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -3471,12 +3471,59 @@ body.objectKanbanColumnResizing * { align-self: stretch; height: 42px; position: relative; + --tab-mask-fade: 14px; } .dashboard-tabs-wrap { flex: 1; min-width: 0; height: 100%; + position: relative; +} + +.dashboard-tabs-wrap .simplebar-mask { + --tab-mask-left: 0px; + --tab-mask-right: 0px; + -webkit-clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left)); + clip-path: inset(0 var(--tab-mask-right) 0 var(--tab-mask-left)); + -webkit-mask-image: linear-gradient( + to right, + transparent var(--tab-mask-left), + #000 calc(var(--tab-mask-left) + var(--tab-mask-fade)), + #000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade)), + transparent calc(100% - var(--tab-mask-right)) + ); + mask-image: linear-gradient( + to right, + transparent var(--tab-mask-left), + #000 calc(var(--tab-mask-left) + var(--tab-mask-fade)), + #000 calc(100% - var(--tab-mask-right) - var(--tab-mask-fade)), + transparent calc(100% - var(--tab-mask-right)) + ); +} + +.dashboard-tabs-wrap::before, +.dashboard-tabs-wrap::after { + content: ''; + position: absolute; + bottom: 0; + width: var(--tab-mask-fade); + height: 1px; + pointer-events: none; + z-index: 2; + background: var(--color-header-border); +} + +.dashboard-tabs-wrap::before { + left: 0; + -webkit-mask-image: linear-gradient(to right, #000, transparent); + mask-image: linear-gradient(to right, #000, transparent); +} + +.dashboard-tabs-wrap::after { + right: 0.2px; + -webkit-mask-image: linear-gradient(to left, #000, transparent); + mask-image: linear-gradient(to left, #000, transparent); } .dashboard-tabs-wrap .simplebar-track.simplebar-vertical { @@ -3498,7 +3545,7 @@ body.objectKanbanColumnResizing * { display: flex; align-items: flex-start; gap: 4px; - padding-left: 14px; + padding-left: 20px; height: 100%; max-height: 42px; } @@ -3555,7 +3602,7 @@ body.objectKanbanColumnResizing * { justify-content: center; overflow: visible; margin: 5px 0 0 0; - padding: 0 8px 0 8px; + padding: 0 10px 0 10px; border: 1px solid var(--color-list-view-tabs-border); border-radius: 12px; background: transparent; @@ -3741,11 +3788,33 @@ body.objectKanbanColumnResizing * { min-width: 0; } -.dashboard-tab-item-container[data-sticky-placeholder='true'] > .dashboard-tab-item { +.dashboard-tab-item-container[data-sticky-placeholder='true'] + > .dashboard-tab-item { visibility: hidden; pointer-events: none; } +.dashboard-tabs[data-sticky-edge] .dashboard-tab-item-container:before, +.dashboard-tabs[data-sticky-edge] .dashboard-tabs-add-button-container:before, +.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::before, +.dashboard-tabs[data-sticky-edge] .dashboard-tabs-wrap::after { + content: none; + display: none; +} + +.dashboard-tabs-sticky-line { + display: none; + position: absolute; + bottom: 0; + pointer-events: none; + z-index: 3; + border-bottom: 1px solid var(--color-header-border); +} + +.dashboard-tabs-sticky-line[data-edge] { + display: block; +} + .dashboard-tab-sticky-clone { position: absolute; top: 0; diff --git a/src/components/Dashboard/common/DashboardTabs.jsx b/src/components/Dashboard/common/DashboardTabs.jsx index d765fdc5..e34783b9 100644 --- a/src/components/Dashboard/common/DashboardTabs.jsx +++ b/src/components/Dashboard/common/DashboardTabs.jsx @@ -136,6 +136,8 @@ DashboardTabItem.propTypes = { } const STICKY_EDGE_INSET = 20 +const STICKY_LINE_CORNER_GAP = 16 +const STICKY_MASK_INSET = 58 const measureTabMetrics = (container) => { const button = container.querySelector('.dashboard-tab-item') @@ -173,6 +175,45 @@ const hideStickyClone = (cloneEl) => { cloneEl.setAttribute('inert', '') } +const setSimplebarMaskInset = (maskEl, edge, inset) => { + if (!maskEl) return + if (!edge) { + maskEl.style.removeProperty('--tab-mask-left') + maskEl.style.removeProperty('--tab-mask-right') + return + } + if (edge === 'left') { + maskEl.style.setProperty('--tab-mask-left', `${inset}px`) + maskEl.style.removeProperty('--tab-mask-right') + } else { + maskEl.style.removeProperty('--tab-mask-left') + maskEl.style.setProperty('--tab-mask-right', `${inset}px`) + } +} + +const setStickyStripLine = (root, lineEl, edge, lineInset) => { + if (root) { + if (!edge) delete root.dataset.stickyEdge + else root.dataset.stickyEdge = edge + } + const padding = 4 + if (!lineEl) return + if (!edge) { + delete lineEl.dataset.edge + lineEl.style.left = '' + lineEl.style.right = '' + return + } + lineEl.dataset.edge = edge + if (edge === 'left') { + lineEl.style.left = `${lineInset + padding}px` + lineEl.style.right = '0px' + } else { + lineEl.style.left = '0px' + lineEl.style.right = `${lineInset + padding}px` + } +} + const setOriginalStickyState = (container, sticky) => { if (!container) return if (sticky) { @@ -184,11 +225,19 @@ const setOriginalStickyState = (container, sticky) => { } } -const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { +const bindActiveTabStickyScroll = (scrollEl, list, cloneEl, root, lineEl) => { const state = { activeEl: null, updating: false } + const maskEl = scrollEl.closest('.simplebar-mask') + const wrapperEl = scrollEl.closest('.simplebar-wrapper') || scrollEl + + const clearStickyVisuals = () => { + hideStickyClone(cloneEl) + setStickyStripLine(root, lineEl, null) + setSimplebarMaskInset(maskEl, null) + } const update = () => { if (state.updating) return @@ -204,7 +253,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { state.activeEl = container if (!container || !cloneEl) { - hideStickyClone(cloneEl) + clearStickyVisuals() return } @@ -213,16 +262,22 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { const minWidth = Math.min(metrics.minWidth, naturalWidth) const maxShrink = Math.max(0, naturalWidth - minWidth) const scrollLeft = scrollEl.scrollLeft - const viewportWidth = scrollEl.clientWidth + const viewportWidth = wrapperEl.clientWidth if (viewportWidth <= 0) { setOriginalStickyState(container, false) - hideStickyClone(cloneEl) + clearStickyVisuals() return } - const scrollRect = scrollEl.getBoundingClientRect() + const wrapperRect = wrapperEl.getBoundingClientRect() const containerRect = container.getBoundingClientRect() - const naturalLeft = scrollLeft + (containerRect.left - scrollRect.left) + const naturalLeft = scrollLeft + (containerRect.left - wrapperRect.left) + const isFirstTab = container === list.firstElementChild + if (isFirstTab && scrollLeft < 1) { + setOriginalStickyState(container, false) + clearStickyVisuals() + return + } const overflowLeft = scrollLeft + STICKY_EDGE_INSET - naturalLeft const overflowRight = naturalLeft + @@ -241,13 +296,15 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { if (!edge) { setOriginalStickyState(container, false) - hideStickyClone(cloneEl) + clearStickyVisuals() return } const shrink = Math.min(overflow, maxShrink) const visualWidth = Math.max(minWidth, naturalWidth - shrink) - const fade = maxShrink === 0 ? 1 : 1 - shrink / maxShrink + const shrinkProgress = maxShrink === 0 ? 1 : shrink / maxShrink + const fade = 1 - shrinkProgress + const maskInset = STICKY_MASK_INSET * shrinkProgress setOriginalStickyState(container, true) cloneEl.removeAttribute('inert') @@ -256,16 +313,27 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { else delete cloneEl.dataset.faded const parentRect = - cloneEl.offsetParent?.getBoundingClientRect() || scrollRect + cloneEl.offsetParent?.getBoundingClientRect() || wrapperRect + const cloneOffset = + edge === 'left' + ? wrapperRect.left - parentRect.left + STICKY_EDGE_INSET + : parentRect.right - wrapperRect.right + STICKY_EDGE_INSET if (edge === 'left') { - cloneEl.style.left = `${scrollRect.left - parentRect.left + STICKY_EDGE_INSET}px` + cloneEl.style.left = `${cloneOffset}px` cloneEl.style.right = 'auto' } else { cloneEl.style.left = 'auto' - cloneEl.style.right = `${parentRect.right - scrollRect.right + STICKY_EDGE_INSET}px` + cloneEl.style.right = `${cloneOffset}px` } cloneEl.style.setProperty('--tab-sticky-width', `${visualWidth}px`) cloneEl.style.setProperty('--tab-label-opacity', String(fade)) + setSimplebarMaskInset(maskEl, edge, maskInset) + setStickyStripLine( + root, + lineEl, + edge, + cloneOffset + visualWidth + STICKY_LINE_CORNER_GAP + ) } finally { state.updating = false } @@ -286,6 +354,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { const observer = new ResizeObserver(scheduleUpdate) observer.observe(scrollEl) + if (wrapperEl !== scrollEl) observer.observe(wrapperEl) return () => { scrollEl.removeEventListener('scroll', scheduleUpdate) @@ -293,7 +362,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { observer.disconnect() if (frame) window.cancelAnimationFrame(frame) setOriginalStickyState(state.activeEl, false) - hideStickyClone(cloneEl) + clearStickyVisuals() } } @@ -301,12 +370,14 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => { const rootRef = useRef(null) const listRef = useRef(null) const cloneRef = useRef(null) + const lineRef = useRef(null) const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}` useLayoutEffect(() => { const root = rootRef.current const list = listRef.current const cloneEl = cloneRef.current + const lineEl = lineRef.current if (!root || !list) return undefined let cancelled = false @@ -320,7 +391,13 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => { frame = window.requestAnimationFrame(tryBind) return } - teardown = bindActiveTabStickyScroll(scrollEl, list, cloneEl) + teardown = bindActiveTabStickyScroll( + scrollEl, + list, + cloneEl, + root, + lineEl + ) } tryBind() @@ -332,7 +409,7 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => { } }, [tabLayoutKey]) - return { rootRef, listRef, cloneRef } + return { rootRef, listRef, cloneRef, lineRef } } const DashboardTabs = () => { @@ -347,7 +424,7 @@ const DashboardTabs = () => { handleTabDragEnd, handleExternalTabDrop } = useNavigationTabs() - const { rootRef, listRef, cloneRef } = useActiveTabStickyScroll( + const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll( activeTabId, tabs ) @@ -591,6 +668,11 @@ const DashboardTabs = () => { +