From 84db1f19b7d006fd2c7e31159cb9b6ca796995e5 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 18 Sep 2026 03:24:56 +0100 Subject: [PATCH] Enhance Dashboard Tabs with Sticky Behavior and Improved Layout - Introduced a new DashboardTabItem component to manage tab interactions, including drag-and-drop functionality. - Added CSS styles for sticky tab behavior, ensuring better visibility and interaction during scrolling. - Updated existing styles to improve layout consistency and responsiveness across dashboard components. - Implemented metrics measurement for tab dimensions to enhance the user experience during tab interactions. --- assets/stylesheets/App.css | 69 ++++ .../Dashboard/common/DashboardTabs.jsx | 357 ++++++++++++++++-- 2 files changed, 392 insertions(+), 34 deletions(-) diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index d3870990..bd5f5d9f 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -3470,6 +3470,7 @@ body.objectKanbanColumnResizing * { min-width: 0; align-self: stretch; height: 42px; + position: relative; } .dashboard-tabs-wrap { @@ -3505,6 +3506,7 @@ body.objectKanbanColumnResizing * { .dashboard-tab-item-container { height: 42px; position: relative; + flex-shrink: 0; } .dashboard-tab-item-container-active { @@ -3566,12 +3568,19 @@ body.objectKanbanColumnResizing * { outline: none; } +.dashboard-tab-item-body { + display: flex; + align-items: center; + min-width: 0; +} + .dashboard-tab-item-icon { display: flex; align-items: center; min-height: 30px; line-height: 30px; margin-right: 6px; + flex-shrink: 0; } .dashboard-tab-item:focus-visible { @@ -3729,6 +3738,66 @@ body.objectKanbanColumnResizing * { .dashboard-tabs-label { max-width: 280px; + min-width: 0; +} + +.dashboard-tab-item-container[data-sticky-placeholder='true'] > .dashboard-tab-item { + visibility: hidden; + pointer-events: none; +} + +.dashboard-tab-sticky-clone { + position: absolute; + top: 0; + height: 42px; + z-index: 5; + visibility: hidden; + pointer-events: none; + box-sizing: border-box; + flex: none; + overflow: visible; +} + +.dashboard-tab-sticky-clone[data-edge] { + visibility: visible; + width: var(--tab-sticky-width); +} + +.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item { + pointer-events: auto; + box-sizing: border-box; + width: var(--tab-sticky-width); + min-width: var(--tab-sticky-width); + max-width: var(--tab-sticky-width); + justify-content: flex-start; + overflow: visible; +} + +.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-body { + width: 100%; + overflow: hidden; +} + +.dashboard-tab-sticky-clone[data-edge] .dashboard-tab-item-icon { + margin-right: calc(6px * var(--tab-label-opacity, 1)); +} + +.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label { + flex: 1 1 auto; + min-width: 0; + opacity: var(--tab-label-opacity, 1); + overflow: hidden; + white-space: nowrap; +} + +.dashboard-tab-sticky-clone[data-edge] .dashboard-tabs-label .ant-typography { + max-width: 100% !important; + min-width: 0; + overflow: hidden; +} + +.dashboard-tab-sticky-clone[data-faded='true'] .dashboard-tabs-label { + pointer-events: none; } .dashboard-tabs-close { diff --git a/src/components/Dashboard/common/DashboardTabs.jsx b/src/components/Dashboard/common/DashboardTabs.jsx index 3ec0d658..d765fdc5 100644 --- a/src/components/Dashboard/common/DashboardTabs.jsx +++ b/src/components/Dashboard/common/DashboardTabs.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types' -import { useCallback, useState } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { Button, Flex, Typography } from 'antd' import classNames from 'classnames' import ScrollBox from './ScrollBox' @@ -64,6 +64,277 @@ DashboardTabLabel.propTypes = { onClose: PropTypes.func } +const DashboardTabItem = ({ + tab, + tabIndex, + isSelected, + isDragging, + dropPosition, + onDragStart, + onDragEnd, + onClick, + onKeyDown, + onDragOver, + onDrop, + onClose +}) => { + const model = tab.modelName ? getModelByName(tab.modelName) : null + const Icon = model?.icon || HomeIcon + + return ( + + ) +} + +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}
) }