Enhance ObjectTable and Sidebar Functionality with Dynamic Width Management

- Introduced a new custom hook, useSidebarWidth, to manage and persist sidebar widths based on user settings, improving user experience and customization.
- Updated ObjectTable to utilize the sidebar width dynamically, enhancing layout responsiveness.
- Implemented visibility logic for vertical scrollbars in the ObjectTable, ensuring better usability during data interaction.
- Enhanced CSS styles for scrollbar transitions and sidebar components, improving visual feedback and interaction.
This commit is contained in:
Tom Butcher 2026-09-05 01:58:44 +01:00
parent becd5d5e82
commit fe2d257653
3 changed files with 214 additions and 22 deletions

View File

@ -739,6 +739,7 @@ body {
.simplebar-scrollbar:before { .simplebar-scrollbar:before {
background: #78787854 !important; background: #78787854 !important;
transition: opacity 0.3s ease;
} }
.printer-alerts-display-popover .ant-popover-inner { .printer-alerts-display-popover .ant-popover-inner {
@ -1612,23 +1613,23 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
top: 0; top: 0;
right: -11px; right: -11px;
bottom: 0; bottom: 0;
width: 11px; width: 6px;
z-index: 3; z-index: 3;
cursor: col-resize; cursor: col-resize;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
pointer-events: auto; pointer-events: auto;
opacity: 0.6;
} }
.objectKanbanColumnResizeHandle::before { .objectKanbanColumnResizeHandle::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 11px; top: 11.15px;
bottom: 11px; bottom: 10.85px;
right: 1px; right: 1px;
width: 2px; width: 2px;
transform: translateX(-50%); transform: translateX(-50%);
opacity: 0.6;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
} }
@ -1640,16 +1641,9 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
width: 2px; width: 2px;
height: 20px; height: 20px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
opacity: 0.6;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
} }
.objectKanbanColumnResizeHandle.active::before,
.objectKanbanColumnResizeHandle.active::after {
background: #0e3a5b;
opacity: 1;
}
.dark-mode .objectKanbanColumnResizeHandle::after { .dark-mode .objectKanbanColumnResizeHandle::after {
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
} }
@ -1658,16 +1652,30 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.dark-mode .objectKanbanColumnResizeHandle.active::before, .light-mode .objectKanbanColumnResizeHandle::after {
.dark-mode .objectKanbanColumnResizeHandle.active::after { background: rgba(0, 0, 0, 0.15);
background: #0b4c7e;
opacity: 1;
} }
.objectKanbanColumnResizeHandle:hover::before { .light-mode .objectKanbanColumnResizeHandle::before {
background: rgba(0, 0, 0, 0.04);
}
.dark-mode .objectKanbanColumnResizeHandle:hover::before {
background: #0e3a5b; background: #0e3a5b;
} }
.light-mode .objectKanbanColumnResizeHandle:hover::before {
background: #e6f8ff;
}
.dark-mode .objectKanbanColumnResizeHandle.active::before {
background: #0b4c7e;
}
.light-mode .objectKanbanColumnResizeHandle.active::before {
background: #a3e3ff;
}
body.objectKanbanColumnResizing, body.objectKanbanColumnResizing,
body.objectKanbanColumnResizing * { body.objectKanbanColumnResizing * {
cursor: col-resize !important; cursor: col-resize !important;
@ -2313,6 +2321,15 @@ body.objectKanbanColumnResizing * {
content: none; content: none;
} }
.farmcontrol-splitter .ant-splitter-bar-dragger {
transition: opacity 0.3s ease;
opacity: 1;
}
.farmcontrol-splitter.vertical-scroll-visible .ant-splitter-bar-dragger {
opacity: 0;
}
.object-display-tag .object-display-tag
.elipsis-text-wrapper.ant-typography .elipsis-text-wrapper.ant-typography
code.elipsis-text-measure, code.elipsis-text-measure,

View File

@ -69,6 +69,9 @@ import {
import { areValuesEqual } from '../utils/Utils' import { areValuesEqual } from '../utils/Utils'
import MissingPlaceholder from './MissingPlaceholder' import MissingPlaceholder from './MissingPlaceholder'
import LoadingPlaceholder from './LoadingPlaceholder' import LoadingPlaceholder from './LoadingPlaceholder'
import useSidebarWidth from '../hooks/useSidebarWidth'
import classNames from 'classnames'
const logger = loglevel.getLogger('DasboardTable') const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -412,6 +415,10 @@ const ObjectTable = forwardRef(
const [sidebarFilter, setSidebarFilter] = useState({}) const [sidebarFilter, setSidebarFilter] = useState({})
const [tableSorter, setTableSorter] = useState({}) const [tableSorter, setTableSorter] = useState({})
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const [sidebarWidth, setSidebarWidth] = useSidebarWidth(type)
const [verticalScrollVisible, setVerticalScrollVisible] = useState(false)
const tableContentRef = useRef(null)
// Keep refs in sync with props. applyViewState may update them earlier in // Keep refs in sync with props. applyViewState may update them earlier in
// the same tick; props remain the source of truth after commit. // the same tick; props remain the source of truth after commit.
@ -1929,7 +1936,11 @@ const ObjectTable = forwardRef(
const cardsContainerNodeRef = useRef(null) const cardsContainerNodeRef = useRef(null)
const cardsResizeObserverRef = useRef(null) const cardsResizeObserverRef = useRef(null)
const measureCardColSpan = useCallback(() => { const measureCardColSpan = useCallback((sizes) => {
if (sizes?.[1]) {
setSidebarWidth(sizes[1])
}
const node = cardsContainerNodeRef.current const node = cardsContainerNodeRef.current
if (!node) return if (!node) return
const panel = node.closest('.ant-splitter-panel') const panel = node.closest('.ant-splitter-panel')
@ -2107,17 +2118,53 @@ const ObjectTable = forwardRef(
) )
useEffect(() => { useEffect(() => {
console.log('loading', loading) const container = tableContentRef.current
}, [loading])
if (!container) return
const updateVisibility = () => {
const scrollableY = container.querySelector('.simplebar-scrollable-y')
const scrollableX = container.querySelector('.simplebar-scrollable-x')
const scrollbar = container.querySelector(
'.simplebar-vertical .simplebar-scrollbar'
)
setVerticalScrollVisible(
scrollbar?.classList.contains('simplebar-visible') ||
scrollableY?.classList.contains('simplebar-mouse-entered') ||
scrollableX?.classList.contains('simplebar-mouse-entered')
)
}
// Initial state
updateVisibility()
const observer = new MutationObserver(updateVisibility)
observer.observe(container, {
subtree: true,
attributes: true,
attributeFilter: ['class']
})
return () => {
observer.disconnect()
}
}, [])
const tableContent = ( const tableContent = (
<Flex <Flex
gap={'middle'} gap={'middle'}
vertical vertical
style={{ flex: 1, minWidth: 0, minHeight: 0 }} style={{ flex: 1, minWidth: 0, minHeight: 0 }}
ref={tableContentRef}
> >
<Splitter <Splitter
className={'farmcontrol-splitter large'} className={classNames('farmcontrol-splitter large', {
'vertical-scroll-visible': verticalScrollVisible
})}
onResize={measureCardColSpan} onResize={measureCardColSpan}
onResizeEnd={measureCardColSpan} onResizeEnd={measureCardColSpan}
> >
@ -2214,8 +2261,8 @@ const ObjectTable = forwardRef(
</Spin> </Spin>
)} )}
</Splitter.Panel> </Splitter.Panel>
{(showSortSidebar || showFilterSidebar) && ( {(showSortSidebar || showFilterSidebar) && initialized && (
<Splitter.Panel defaultSize={'25%'}> <Splitter.Panel size={sidebarWidth}>
<Flex <Flex
vertical vertical
gap='large' gap='large'

View File

@ -0,0 +1,128 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { ApiServerContext } from '../context/ApiServerContext'
const useSidebarWidth = (componentName, defaultValue = 300) => {
const { userSettings, updateUserSettings } = useContext(ApiServerContext)
const savedSidebarWidth =
userSettings?.sidebarWidths?.[componentName] ?? defaultValue
const [localSidebarWidth, setLocalSidebarWidth] = useState(savedSidebarWidth)
const pendingValueRef = useRef(null)
const lastUpdateRef = useRef(0)
const timeoutRef = useRef(null)
/*
* Keep the latest updateUserSettings arguments in refs so the
* cleanup function always has the correct component name/value.
*/
const componentNameRef = useRef(componentName)
const updateUserSettingsRef = useRef(updateUserSettings)
componentNameRef.current = componentName
updateUserSettingsRef.current = updateUserSettings
const callUpdateUserSettings = useCallback((name, value) => {
lastUpdateRef.current = Date.now()
updateUserSettingsRef.current('sidebarWidths', name, value)
}, [])
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
const pending = pendingValueRef.current
if (!pending) return
pendingValueRef.current = null
callUpdateUserSettings(pending.componentName, pending.value)
}, [callUpdateUserSettings])
/*
* Flush the previous component's value when the component is
* unmounted OR componentName changes.
*/
useEffect(() => {
const name = componentName
return () => {
const pending = pendingValueRef.current
if (pending && pending.componentName === name) {
flush()
}
}
}, [componentName, flush])
/*
* Load the saved width when the component name changes.
*/
useEffect(() => {
setLocalSidebarWidth(savedSidebarWidth)
}, [componentName, savedSidebarWidth])
const setSidebarWidth = useCallback(
(nextValue) => {
const value =
typeof nextValue === 'function'
? nextValue(localSidebarWidth)
: nextValue
if (value === localSidebarWidth) return
// Always update the UI immediately.
setLocalSidebarWidth(value)
// Keep only the latest value waiting to be persisted.
pendingValueRef.current = {
componentName,
value
}
const elapsed = Date.now() - lastUpdateRef.current
/*
* We haven't called updateUserSettings recently, so call it
* immediately.
*/
if (elapsed >= 1000) {
const pending = pendingValueRef.current
pendingValueRef.current = null
callUpdateUserSettings(pending.componentName, pending.value)
return
}
/*
* Already rate limited. Schedule the next call for exactly
* when the 1 second window expires.
*/
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null
const pending = pendingValueRef.current
if (!pending) return
pendingValueRef.current = null
callUpdateUserSettings(pending.componentName, pending.value)
}, 1000 - elapsed)
}
},
[componentName, localSidebarWidth, callUpdateUserSettings]
)
return [localSidebarWidth, setSidebarWidth]
}
export default useSidebarWidth