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.
This commit is contained in:
Tom Butcher 2026-09-18 03:24:56 +01:00
parent 5498aa3368
commit 84db1f19b7
2 changed files with 392 additions and 34 deletions

View File

@ -3470,6 +3470,7 @@ body.objectKanbanColumnResizing * {
min-width: 0; min-width: 0;
align-self: stretch; align-self: stretch;
height: 42px; height: 42px;
position: relative;
} }
.dashboard-tabs-wrap { .dashboard-tabs-wrap {
@ -3505,6 +3506,7 @@ body.objectKanbanColumnResizing * {
.dashboard-tab-item-container { .dashboard-tab-item-container {
height: 42px; height: 42px;
position: relative; position: relative;
flex-shrink: 0;
} }
.dashboard-tab-item-container-active { .dashboard-tab-item-container-active {
@ -3566,12 +3568,19 @@ body.objectKanbanColumnResizing * {
outline: none; outline: none;
} }
.dashboard-tab-item-body {
display: flex;
align-items: center;
min-width: 0;
}
.dashboard-tab-item-icon { .dashboard-tab-item-icon {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 30px; min-height: 30px;
line-height: 30px; line-height: 30px;
margin-right: 6px; margin-right: 6px;
flex-shrink: 0;
} }
.dashboard-tab-item:focus-visible { .dashboard-tab-item:focus-visible {
@ -3729,6 +3738,66 @@ body.objectKanbanColumnResizing * {
.dashboard-tabs-label { .dashboard-tabs-label {
max-width: 280px; 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 { .dashboard-tabs-close {

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useCallback, useState } from 'react' import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Button, Flex, Typography } from 'antd' import { Button, Flex, Typography } from 'antd'
import classNames from 'classnames' import classNames from 'classnames'
import ScrollBox from './ScrollBox' import ScrollBox from './ScrollBox'
@ -64,6 +64,277 @@ DashboardTabLabel.propTypes = {
onClose: PropTypes.func 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 (
<button
type='button'
role='tab'
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
className={classNames(
'dashboard-tab-item',
'electrobun-webkit-app-region-no-drag',
{
'dashboard-tab-item-active': isSelected,
'dashboard-tab-item-dragging': isDragging,
'dashboard-tab-item-drop-before': dropPosition === 'before',
'dashboard-tab-item-drop-after': dropPosition === 'after'
}
)}
draggable
onDragStart={(event) => onDragStart(event, tab.id)}
onDragEnd={onDragEnd}
onClick={() => onClick(tab.id)}
onKeyDown={(event) => onKeyDown(event, tabIndex)}
onDragOver={(event) => onDragOver(event, tab)}
onDrop={(event) => onDrop(event, tab)}
>
<DashboardTabChrome />
<span className='dashboard-tab-item-body'>
<span className='dashboard-tab-item-icon'>
<Icon style={{ fontSize: 14 }} color='secondary' />
</span>
<DashboardTabLabel tab={tab} onClose={onClose} />
</span>
</button>
)
}
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 DashboardTabs = () => {
const { const {
tabs, tabs,
@ -76,6 +347,10 @@ const DashboardTabs = () => {
handleTabDragEnd, handleTabDragEnd,
handleExternalTabDrop handleExternalTabDrop
} = useNavigationTabs() } = useNavigationTabs()
const { rootRef, listRef, cloneRef } = useActiveTabStickyScroll(
activeTabId,
tabs
)
const [draggedValue, setDraggedValue] = useState(null) const [draggedValue, setDraggedValue] = useState(null)
const [dropTarget, setDropTarget] = useState(null) const [dropTarget, setDropTarget] = useState(null)
@ -232,9 +507,24 @@ const DashboardTabs = () => {
} }
const isReordering = draggedValue != null || dropTarget != null 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 ( return (
<Flex <Flex
ref={rootRef}
align='flex-start' align='flex-start'
gap={4} gap={4}
className={classNames( className={classNames(
@ -248,6 +538,7 @@ const DashboardTabs = () => {
<ScrollBox className='dashboard-tabs-wrap' horizontal> <ScrollBox className='dashboard-tabs-wrap' horizontal>
<Flex align='flex-start' gap={8} className='dashboard-tabs-inner'> <Flex align='flex-start' gap={8} className='dashboard-tabs-inner'>
<div <div
ref={listRef}
className={classNames('dashboard-tabs-list', { className={classNames('dashboard-tabs-list', {
'dashboard-tabs-reordering': isReordering 'dashboard-tabs-reordering': isReordering
})} })}
@ -256,8 +547,6 @@ const DashboardTabs = () => {
onDrop={handleListDrop} onDrop={handleListDrop}
> >
{tabs.map((tab, index) => { {tabs.map((tab, index) => {
const model = tab.modelName ? getModelByName(tab.modelName) : null
const Icon = model?.icon || HomeIcon
const isSelected = tab.id === activeTabId const isSelected = tab.id === activeTabId
const isDragging = const isDragging =
draggedValue != null && String(draggedValue) === String(tab.id) draggedValue != null && String(draggedValue) === String(tab.id)
@ -273,37 +562,20 @@ const DashboardTabs = () => {
'dashboard-tab-item-container-active': isSelected 'dashboard-tab-item-container-active': isSelected
})} })}
> >
<button <DashboardTabItem
type='button' tab={tab}
role='tab' tabIndex={index}
aria-selected={isSelected} isSelected={isSelected}
tabIndex={isSelected ? 0 : -1} isDragging={isDragging}
className={classNames( dropPosition={
'dashboard-tab-item', isDropTarget
'electrobun-webkit-app-region-no-drag', ? dropTarget.insertBefore
{ ? 'before'
'dashboard-tab-item-active': isSelected, : 'after'
'dashboard-tab-item-dragging': isDragging, : undefined
'dashboard-tab-item-drop-before': }
isDropTarget && dropTarget.insertBefore, {...tabItemProps}
'dashboard-tab-item-drop-after': />
isDropTarget && !dropTarget.insertBefore
}
)}
draggable
onDragStart={(event) => handleItemDragStart(event, tab.id)}
onDragEnd={handleItemDragEnd}
onClick={() => selectTab(tab.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
onDragOver={(event) => handleItemDragOver(event, tab)}
onDrop={(event) => handleItemDrop(event, tab)}
>
<DashboardTabChrome />
<span className='dashboard-tab-item-icon'>
<Icon style={{ fontSize: 14 }} color='secondary' />
</span>
<DashboardTabLabel tab={tab} onClose={closeTab} />
</button>
</div> </div>
) )
})} })}
@ -319,6 +591,23 @@ const DashboardTabs = () => {
</div> </div>
</Flex> </Flex>
</ScrollBox> </ScrollBox>
{activeTab ? (
<div
ref={cloneRef}
className='dashboard-tab-sticky-clone dashboard-tab-item-container dashboard-tab-item-container-active'
>
<DashboardTabItem
tab={activeTab}
tabIndex={activeTabIndex}
isSelected
isDragging={
draggedValue != null &&
String(draggedValue) === String(activeTab.id)
}
{...tabItemProps}
/>
</div>
) : null}
</Flex> </Flex>
) )
} }