Tom Butcher d5096dbba3
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor Dashboard Tabs and Enhance CSS Styles
- Updated CSS for dashboard tabs to improve layout and overflow handling, ensuring better visibility and usability.
- Replaced Typography's Text component with a custom ElipsisText component for improved text overflow management.
- Simplified tab selection logic in NavigationTabsContext, enhancing scrolling behavior when selecting tabs.
- Introduced a new utility function to scroll tabs into view, improving user experience during navigation.
2026-09-21 01:58:03 +01:00

804 lines
22 KiB
JavaScript

import PropTypes from 'prop-types'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
memo
} from 'react'
import { Button, Flex, Popover } from 'antd'
import DashboardTabPreview from './DashboardTabPreview'
import ElipsisText from './ElipsisText'
import classNames from 'classnames'
import ScrollBox from './ScrollBox'
import PlusIcon from '../../Icons/PlusIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import HomeIcon from '../../Icons/HomeIcon'
import { getModelByName } from '../../../database/ObjectModels'
import { getSidebarIconComponent } from '../../Icons/sidebarIconMap'
import {
useNavigationTabs,
useTabPreview
} from '../context/NavigationTabsContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
import { hasExternalTabDrag, writeTabDragData } from './tabDrag'
const DashboardTabChrome = () => (
<>
<span className='dashboard-tab-item-outline-container' aria-hidden='true'>
<span className='dashboard-tab-item-outline' aria-hidden='true' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-left'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
<span
className='dashboard-tab-item-corner dashboard-tab-item-corner-right'
aria-hidden='true'
>
<span className='dashboard-tab-item-corner-fill' />
<span className='dashboard-tab-item-corner-inner' />
</span>
</>
)
const DashboardTabLabel = ({ tab, onClose }) => (
<Flex align='center' gap={6} className='dashboard-tabs-label'>
<ElipsisText>{tab.title || 'Farm Control'}</ElipsisText>
<span
className='dashboard-tabs-close'
role='button'
tabIndex={-1}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onClose?.(tab.id)
}}
onMouseDown={(event) => event.stopPropagation()}
>
<XMarkIcon style={{ fontSize: 10 }} />
</span>
</Flex>
)
DashboardTabLabel.propTypes = {
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string
}).isRequired,
onClose: PropTypes.func
}
const DashboardTabItem = memo(function DashboardTabItem({
htmlId = undefined,
tab,
tabIndex,
isSelected,
isDragging,
dropPosition,
onDragStart,
onDragEnd,
onClick,
onKeyDown,
onDragOver,
onDrop,
onClose,
captureTabPreview
}) {
const {
src: previewSrc,
theme: previewTheme,
capturing: previewCapturing
} = useTabPreview(tab.id)
const [previewOpen, setPreviewOpen] = useState(false)
const previewTargetRef = useRef(null)
const model = tab.modelName ? getModelByName(tab.modelName) : null
const SidebarIcon = getSidebarIconComponent(tab.iconKey)
const Icon = model?.icon || SidebarIcon || HomeIcon
const handlePreviewOpenChange = useCallback(
(nextOpen) => {
if (isSelected || isDragging) {
setPreviewOpen(false)
return
}
setPreviewOpen(nextOpen)
if (nextOpen) {
void captureTabPreview(tab.id)
}
},
[captureTabPreview, isDragging, isSelected, tab.id]
)
useEffect(() => {
if (isSelected || isDragging) {
setPreviewOpen(false)
}
}, [isDragging, isSelected])
const tabButton = (
<button
ref={previewTargetRef}
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}
id={htmlId}
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>
)
if (isSelected) {
return tabButton
}
return (
<Popover
trigger='hover'
placement='bottom'
arrow={false}
mouseEnterDelay={1}
mouseLeaveDelay={0}
destroyOnHidden
open={isDragging ? false : previewOpen}
onOpenChange={handlePreviewOpenChange}
content={
<DashboardTabPreview
src={previewSrc}
srcTheme={previewTheme}
loading={previewCapturing}
/>
}
classNames={{ root: 'dashboard-tab-preview-popover' }}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
>
{tabButton}
</Popover>
)
})
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = {
htmlId: PropTypes.string,
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string,
modelName: PropTypes.string,
iconKey: 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,
captureTabPreview: PropTypes.func
}
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')
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 setSimplebarMaskInset = (maskEl, edge, inset) => {
if (!maskEl) return
const wrapEl = maskEl.closest('.dashboard-tabs-wrap') || maskEl
if (!edge) {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.removeProperty('--tab-mask-right')
return
}
if (edge === 'left') {
wrapEl.style.setProperty('--tab-mask-left', `${inset}px`)
wrapEl.style.removeProperty('--tab-mask-right')
} else {
wrapEl.style.removeProperty('--tab-mask-left')
wrapEl.style.setProperty('--tab-mask-right', `${inset}px`)
}
}
const setScrollEdgeFades = (root, scrollEl) => {
if (!root) return
if (!scrollEl) {
root.dataset.atStart = ''
root.dataset.atEnd = ''
return
}
const atStart = scrollEl.scrollLeft <= 1
const atEnd =
scrollEl.scrollLeft + scrollEl.clientWidth >= scrollEl.scrollWidth - 1
if (atStart) root.dataset.atStart = ''
else delete root.dataset.atStart
if (atEnd) root.dataset.atEnd = ''
else delete root.dataset.atEnd
}
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) {
container.dataset.stickyPlaceholder = 'true'
container.setAttribute('inert', '')
} else {
delete container.dataset.stickyPlaceholder
container.removeAttribute('inert')
}
}
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
state.updating = true
try {
setScrollEdgeFades(root, scrollEl)
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) {
clearStickyVisuals()
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 = wrapperEl.clientWidth
if (viewportWidth <= 0) {
setOriginalStickyState(container, false)
clearStickyVisuals()
return
}
const wrapperRect = wrapperEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
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 +
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)
clearStickyVisuals()
return
}
const shrink = Math.min(overflow, maxShrink)
const visualWidth = Math.max(minWidth, naturalWidth - shrink)
const shrinkProgress = maxShrink === 0 ? 1 : shrink / maxShrink
const fade = 1 - shrinkProgress
const maskInset = STICKY_MASK_INSET * shrinkProgress
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() || 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 = `${cloneOffset}px`
cloneEl.style.right = 'auto'
} else {
cloneEl.style.left = 'auto'
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
}
}
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)
if (wrapperEl !== scrollEl) observer.observe(wrapperEl)
observer.observe(list)
return () => {
scrollEl.removeEventListener('scroll', scheduleUpdate)
window.removeEventListener('resize', scheduleUpdate)
observer.disconnect()
if (frame) window.cancelAnimationFrame(frame)
setOriginalStickyState(state.activeEl, false)
clearStickyVisuals()
delete root.dataset.atStart
delete root.dataset.atEnd
}
}
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
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,
root,
lineEl
)
}
tryBind()
return () => {
cancelled = true
if (frame) window.cancelAnimationFrame(frame)
teardown()
}
}, [tabLayoutKey])
return { rootRef, listRef, cloneRef, lineRef }
}
const DashboardTabs = () => {
const {
tabs,
activeTabId,
selectTab,
addTab,
registerTabStripRoot,
closeTab,
reorderTabs,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId,
tabs
)
const [draggedValue, setDraggedValue] = useState(null)
const [dropTarget, setDropTarget] = useState(null)
const clearDragState = useCallback(() => {
setDraggedValue(null)
setDropTarget(null)
}, [])
const handleItemDragStart = (event, tabId) => {
const tab = tabs.find((item) => item.id === tabId)
if (!tab) return
event.stopPropagation()
const item = event.currentTarget
if (item) {
const rect = item.getBoundingClientRect()
event.dataTransfer.setDragImage(
item,
event.clientX - rect.left,
event.clientY - rect.top
)
}
setDraggedValue(tabId)
event.dataTransfer.effectAllowed = 'move'
writeTabDragData(event, {
tab,
sourceWindowId: getDesktopWindowId()
})
handleTabDragStart(tabId)
}
const handleItemDragEnd = () => {
handleTabDragEnd()
clearDragState()
}
const handleItemDragOver = (event, tab) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (draggedValue != null && String(tab.id) === String(draggedValue)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'move'
const rect = event.currentTarget.getBoundingClientRect()
setDropTarget({
value: tab.id,
insertBefore: event.clientX < rect.left + rect.width / 2
})
}
const handleItemDrop = (event, tab) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
event.preventDefault()
event.stopPropagation()
const target = dropTarget || {
value: tab.id,
insertBefore: true
}
if (isExternal) {
void handleExternalTabDrop(target.value, target.insertBefore)
clearDragState()
return
}
if (draggedValue == null) return
if (String(draggedValue) !== String(target.value)) {
reorderTabs(draggedValue, target.value, target.insertBefore)
}
clearDragState()
}
const handleListDragOver = (event) => {
const isExternal = hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const lastTab = tabs[tabs.length - 1]
if (!lastTab) return
setDropTarget({
value: lastTab.id,
insertBefore: false
})
}
const handleListDrop = (event) => {
const isExternal = draggedValue == null && hasExternalTabDrag(event)
if (draggedValue == null && !isExternal) return
if (
event.target instanceof Element &&
event.target.closest('.dashboard-tab-item')
) {
return
}
event.preventDefault()
event.stopPropagation()
const lastTab = tabs[tabs.length - 1]
if (!lastTab) {
clearDragState()
return
}
if (isExternal) {
void handleExternalTabDrop(lastTab.id, false)
clearDragState()
return
}
if (String(draggedValue) !== String(lastTab.id)) {
reorderTabs(draggedValue, lastTab.id, false)
}
clearDragState()
}
const handleStripDragOver = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const handleStripDrop = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
const lastTab = tabs[tabs.length - 1]
void handleExternalTabDrop(lastTab?.id, false)
}
const handleKeyDown = (event, tabIndex) => {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId)
const startIndex = currentIndex === -1 ? tabIndex : currentIndex
const nextIndex = (startIndex + direction + tabs.length) % tabs.length
selectTab(tabs[nextIndex].id)
}
const setRootRef = useCallback(
(element) => {
rootRef.current = element
registerTabStripRoot(element)
},
[registerTabStripRoot]
)
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,
captureTabPreview
}
return (
<Flex
ref={setRootRef}
align='flex-start'
gap={4}
className={classNames(
'dashboard-tabs',
'electrobun-webkit-app-region-drag'
)}
style={{ flex: 1, minWidth: 0 }}
onDragOver={handleStripDragOver}
onDrop={handleStripDrop}
>
<ScrollBox className='dashboard-tabs-wrap' horizontal>
<Flex align='flex-start' gap={5} className='dashboard-tabs-inner'>
<div
ref={listRef}
className={classNames('dashboard-tabs-list', {
'dashboard-tabs-reordering': isReordering
})}
role='tablist'
onDragOver={handleListDragOver}
onDrop={handleListDrop}
>
{tabs.map((tab, index) => {
const isSelected = tab.id === activeTabId
const isDragging =
draggedValue != null && String(draggedValue) === String(tab.id)
const isDropTarget =
dropTarget != null &&
String(dropTarget.value) === String(tab.id) &&
String(draggedValue) !== String(tab.id)
return (
<div
key={tab.id}
className={classNames('dashboard-tab-item-container', {
'dashboard-tab-item-container-active': isSelected
})}
>
<DashboardTabItem
tab={tab}
tabIndex={index}
isSelected={isSelected}
isDragging={isDragging}
htmlId={`dashboard-tab-item-${tab.id}`}
dropPosition={
isDropTarget
? dropTarget.insertBefore
? 'before'
: 'after'
: undefined
}
{...tabItemProps}
/>
</div>
)
})}
</div>
<div className='dashboard-tabs-add-button-container'>
<Button
type='text'
className='dashboard-tabs-add-button electrobun-webkit-app-region-no-drag'
onClick={() => addTab()}
icon={<PlusIcon style={{ fontSize: 14, marginTop: 3 }} />}
/>
</div>
</Flex>
</ScrollBox>
<div
ref={lineRef}
className='dashboard-tabs-sticky-line'
aria-hidden='true'
/>
{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>
)
}
export default DashboardTabs