Enhance Dashboard Tabs with Sticky Visuals and Improved Masking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-09-18 04:31:13 +01:00
parent 84db1f19b7
commit 9ca872e589
2 changed files with 169 additions and 18 deletions

View File

@ -3471,12 +3471,59 @@ body.objectKanbanColumnResizing * {
align-self: stretch; align-self: stretch;
height: 42px; height: 42px;
position: relative; position: relative;
--tab-mask-fade: 14px;
} }
.dashboard-tabs-wrap { .dashboard-tabs-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 100%; 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 { .dashboard-tabs-wrap .simplebar-track.simplebar-vertical {
@ -3498,7 +3545,7 @@ body.objectKanbanColumnResizing * {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 4px; gap: 4px;
padding-left: 14px; padding-left: 20px;
height: 100%; height: 100%;
max-height: 42px; max-height: 42px;
} }
@ -3555,7 +3602,7 @@ body.objectKanbanColumnResizing * {
justify-content: center; justify-content: center;
overflow: visible; overflow: visible;
margin: 5px 0 0 0; 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: 1px solid var(--color-list-view-tabs-border);
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
@ -3741,11 +3788,33 @@ body.objectKanbanColumnResizing * {
min-width: 0; 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; visibility: hidden;
pointer-events: none; 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 { .dashboard-tab-sticky-clone {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -136,6 +136,8 @@ DashboardTabItem.propTypes = {
} }
const STICKY_EDGE_INSET = 20 const STICKY_EDGE_INSET = 20
const STICKY_LINE_CORNER_GAP = 16
const STICKY_MASK_INSET = 58
const measureTabMetrics = (container) => { const measureTabMetrics = (container) => {
const button = container.querySelector('.dashboard-tab-item') const button = container.querySelector('.dashboard-tab-item')
@ -173,6 +175,45 @@ const hideStickyClone = (cloneEl) => {
cloneEl.setAttribute('inert', '') 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) => { const setOriginalStickyState = (container, sticky) => {
if (!container) return if (!container) return
if (sticky) { if (sticky) {
@ -184,11 +225,19 @@ const setOriginalStickyState = (container, sticky) => {
} }
} }
const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => { const bindActiveTabStickyScroll = (scrollEl, list, cloneEl, root, lineEl) => {
const state = { const state = {
activeEl: null, activeEl: null,
updating: false 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 = () => { const update = () => {
if (state.updating) return if (state.updating) return
@ -204,7 +253,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
state.activeEl = container state.activeEl = container
if (!container || !cloneEl) { if (!container || !cloneEl) {
hideStickyClone(cloneEl) clearStickyVisuals()
return return
} }
@ -213,16 +262,22 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
const minWidth = Math.min(metrics.minWidth, naturalWidth) const minWidth = Math.min(metrics.minWidth, naturalWidth)
const maxShrink = Math.max(0, naturalWidth - minWidth) const maxShrink = Math.max(0, naturalWidth - minWidth)
const scrollLeft = scrollEl.scrollLeft const scrollLeft = scrollEl.scrollLeft
const viewportWidth = scrollEl.clientWidth const viewportWidth = wrapperEl.clientWidth
if (viewportWidth <= 0) { if (viewportWidth <= 0) {
setOriginalStickyState(container, false) setOriginalStickyState(container, false)
hideStickyClone(cloneEl) clearStickyVisuals()
return return
} }
const scrollRect = scrollEl.getBoundingClientRect() const wrapperRect = wrapperEl.getBoundingClientRect()
const containerRect = container.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 overflowLeft = scrollLeft + STICKY_EDGE_INSET - naturalLeft
const overflowRight = const overflowRight =
naturalLeft + naturalLeft +
@ -241,13 +296,15 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
if (!edge) { if (!edge) {
setOriginalStickyState(container, false) setOriginalStickyState(container, false)
hideStickyClone(cloneEl) clearStickyVisuals()
return return
} }
const shrink = Math.min(overflow, maxShrink) const shrink = Math.min(overflow, maxShrink)
const visualWidth = Math.max(minWidth, naturalWidth - shrink) 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) setOriginalStickyState(container, true)
cloneEl.removeAttribute('inert') cloneEl.removeAttribute('inert')
@ -256,16 +313,27 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
else delete cloneEl.dataset.faded else delete cloneEl.dataset.faded
const parentRect = 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') { if (edge === 'left') {
cloneEl.style.left = `${scrollRect.left - parentRect.left + STICKY_EDGE_INSET}px` cloneEl.style.left = `${cloneOffset}px`
cloneEl.style.right = 'auto' cloneEl.style.right = 'auto'
} else { } else {
cloneEl.style.left = 'auto' 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-sticky-width', `${visualWidth}px`)
cloneEl.style.setProperty('--tab-label-opacity', String(fade)) cloneEl.style.setProperty('--tab-label-opacity', String(fade))
setSimplebarMaskInset(maskEl, edge, maskInset)
setStickyStripLine(
root,
lineEl,
edge,
cloneOffset + visualWidth + STICKY_LINE_CORNER_GAP
)
} finally { } finally {
state.updating = false state.updating = false
} }
@ -286,6 +354,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
const observer = new ResizeObserver(scheduleUpdate) const observer = new ResizeObserver(scheduleUpdate)
observer.observe(scrollEl) observer.observe(scrollEl)
if (wrapperEl !== scrollEl) observer.observe(wrapperEl)
return () => { return () => {
scrollEl.removeEventListener('scroll', scheduleUpdate) scrollEl.removeEventListener('scroll', scheduleUpdate)
@ -293,7 +362,7 @@ const bindActiveTabStickyScroll = (scrollEl, list, cloneEl) => {
observer.disconnect() observer.disconnect()
if (frame) window.cancelAnimationFrame(frame) if (frame) window.cancelAnimationFrame(frame)
setOriginalStickyState(state.activeEl, false) setOriginalStickyState(state.activeEl, false)
hideStickyClone(cloneEl) clearStickyVisuals()
} }
} }
@ -301,12 +370,14 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => {
const rootRef = useRef(null) const rootRef = useRef(null)
const listRef = useRef(null) const listRef = useRef(null)
const cloneRef = useRef(null) const cloneRef = useRef(null)
const lineRef = useRef(null)
const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}` const tabLayoutKey = `${activeTabId}:${tabs.map((tab) => `${tab.id}:${tab.title || ''}`).join('|')}`
useLayoutEffect(() => { useLayoutEffect(() => {
const root = rootRef.current const root = rootRef.current
const list = listRef.current const list = listRef.current
const cloneEl = cloneRef.current const cloneEl = cloneRef.current
const lineEl = lineRef.current
if (!root || !list) return undefined if (!root || !list) return undefined
let cancelled = false let cancelled = false
@ -320,7 +391,13 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => {
frame = window.requestAnimationFrame(tryBind) frame = window.requestAnimationFrame(tryBind)
return return
} }
teardown = bindActiveTabStickyScroll(scrollEl, list, cloneEl) teardown = bindActiveTabStickyScroll(
scrollEl,
list,
cloneEl,
root,
lineEl
)
} }
tryBind() tryBind()
@ -332,7 +409,7 @@ const useActiveTabStickyScroll = (activeTabId, tabs) => {
} }
}, [tabLayoutKey]) }, [tabLayoutKey])
return { rootRef, listRef, cloneRef } return { rootRef, listRef, cloneRef, lineRef }
} }
const DashboardTabs = () => { const DashboardTabs = () => {
@ -347,7 +424,7 @@ const DashboardTabs = () => {
handleTabDragEnd, handleTabDragEnd,
handleExternalTabDrop handleExternalTabDrop
} = useNavigationTabs() } = useNavigationTabs()
const { rootRef, listRef, cloneRef } = useActiveTabStickyScroll( const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId, activeTabId,
tabs tabs
) )
@ -591,6 +668,11 @@ const DashboardTabs = () => {
</div> </div>
</Flex> </Flex>
</ScrollBox> </ScrollBox>
<div
ref={lineRef}
className='dashboard-tabs-sticky-line'
aria-hidden='true'
/>
{activeTab ? ( {activeTab ? (
<div <div
ref={cloneRef} ref={cloneRef}