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 = () => { +
{activeTab ? (