-
Notifications
You must be signed in to change notification settings - Fork 33
feat: DH-19722: Better handling for large numbers of open dashboards #2481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
c9e8783
7db8f1c
3abbd8e
b95b13b
d2cee32
c587f40
148590a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
@import '@deephaven/components/scss/custom.scss'; | ||
|
||
$dashboard-list-color: $gray-200; | ||
$dashboard-list-hover-color: $foreground; | ||
$dashboard-list-background-hover-color: var(--dh-color-highlight-hover); | ||
|
||
$dashboard-list-owner-color: $gray-400; | ||
$dashboard-list-owner-hover-color: $gray-200; | ||
|
||
.dashboard-list-container { | ||
height: 25.6rem; | ||
padding: 0 0.5rem; | ||
text-align: left; | ||
width: 23rem; | ||
|
||
&:focus { | ||
outline: none; | ||
} | ||
|
||
.dashboard-list { | ||
margin: 0.5rem -0.5rem 0; | ||
padding: 0; | ||
text-align: left; | ||
overflow: auto; | ||
li { | ||
list-style: none; | ||
padding: 0; | ||
margin: 0; | ||
} | ||
.btn { | ||
border: none; | ||
border-radius: 0; | ||
display: block; | ||
text-align: left; | ||
margin: 0; | ||
padding: 0.35rem 0.5rem; | ||
width: 100%; | ||
color: $dashboard-list-color; | ||
&:hover, | ||
&:focus, | ||
&.focused { | ||
color: $dashboard-list-hover-color; | ||
background-color: $dashboard-list-background-hover-color; | ||
text-decoration: none; | ||
} | ||
} | ||
.dashboard-list-item-icon { | ||
margin-right: $spacer-2; | ||
} | ||
.dashboard-list-message { | ||
padding: 0 $spacer-2; | ||
} | ||
} | ||
|
||
.dashboard-list-header { | ||
padding-top: $spacer-2; | ||
padding-bottom: $spacer-2; | ||
|
||
.btn .fa-md { | ||
margin-right: $spacer-2; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import React, { | ||
type ChangeEvent, | ||
useCallback, | ||
useEffect, | ||
useMemo, | ||
useRef, | ||
useState, | ||
} from 'react'; | ||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | ||
import { type IconDefinition } from '@fortawesome/fontawesome-svg-core'; | ||
import { EMPTY_ARRAY } from '@deephaven/utils'; | ||
import { Button } from '../Button'; | ||
import SearchInput from '../SearchInput'; | ||
import type { NavTabItem } from './NavTabList'; | ||
import './DashboardList.scss'; | ||
import { GLOBAL_SHORTCUTS } from '../shortcuts'; | ||
|
||
export interface DashboardListProps { | ||
onSelect: (tab: NavTabItem) => void; | ||
tabs?: NavTabItem[]; | ||
} | ||
|
||
/** | ||
* Display a search field and a list of dashboard tabs | ||
* @param props The tabs and handlers to use for this list | ||
* @returns A JSX element for the list of dashboard tabs, along with search | ||
*/ | ||
export function DashboardList(props: DashboardListProps): JSX.Element { | ||
const { onSelect, tabs = EMPTY_ARRAY } = props; | ||
const [searchText, setSearchText] = useState(''); | ||
const [focusedIndex, setFocusedIndex] = useState(0); | ||
const searchField = useRef<SearchInput>(null); | ||
const listRef = useRef<HTMLUListElement>(null); | ||
const itemRefs = useRef<(HTMLLIElement | null)[]>([]); | ||
|
||
useEffect(() => { | ||
searchField.current?.focus(); | ||
}, []); | ||
|
||
useEffect(() => { | ||
setFocusedIndex(0); | ||
}, [searchText]); | ||
|
||
useEffect(() => { | ||
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) { | ||
itemRefs.current[focusedIndex]?.scrollIntoView({ | ||
behavior: 'auto', | ||
block: 'nearest', | ||
}); | ||
} | ||
}, [focusedIndex]); | ||
|
||
const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { | ||
setSearchText(e.target.value); | ||
}, []); | ||
|
||
const handleTabSelect = useCallback( | ||
(tab: NavTabItem) => { | ||
onSelect(tab); | ||
}, | ||
[onSelect] | ||
); | ||
|
||
const handleMouseDown = useCallback((event: React.MouseEvent) => { | ||
// Prevent mousedown from taking focus away from the search input | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of mousedown on the dashboard list stealing focus from the search input. The list items already have CSS styling for this, so I think retaining focus on the search input keeps things cleaner. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I generally try to avoid capturing just a mouse down or a mouse up, as that could potentially cause issues for any other libraries or cause issues with accessibility... seems okay for this case. |
||
event.preventDefault(); | ||
}, []); | ||
|
||
const filteredTabs = useMemo( | ||
() => | ||
tabs.filter(tab => | ||
tab.title.toLowerCase().includes(searchText.toLowerCase()) | ||
), | ||
[searchText, tabs] | ||
).sort((a, b) => a.title.localeCompare(b.title) ?? 0); | ||
|
||
const handleSearchKeyDown = useCallback( | ||
(event: React.KeyboardEvent) => { | ||
if (event.key === 'ArrowDown' && filteredTabs.length > 0) { | ||
event.preventDefault(); | ||
setFocusedIndex(prev => | ||
prev === -1 ? 0 : (prev + 1) % filteredTabs.length | ||
); | ||
} else if (event.key === 'ArrowUp' && filteredTabs.length > 0) { | ||
event.preventDefault(); | ||
setFocusedIndex(prev => { | ||
if (prev === -1) return filteredTabs.length - 1; | ||
return (prev - 1 + filteredTabs.length) % filteredTabs.length; | ||
}); | ||
} else if (event.key === 'Enter' && filteredTabs.length > 0) { | ||
event.preventDefault(); | ||
const selectedIndex = focusedIndex >= 0 ? focusedIndex : 0; | ||
handleTabSelect(filteredTabs[selectedIndex]); | ||
} else if (event.key === 'Tab') { | ||
event.preventDefault(); | ||
} | ||
}, | ||
[filteredTabs, focusedIndex, handleTabSelect] | ||
); | ||
|
||
const tabElements = useMemo( | ||
() => | ||
filteredTabs.map((tab, index) => ( | ||
<li | ||
key={tab.key} | ||
ref={(el: HTMLLIElement | null) => { | ||
itemRefs.current[index] = el; | ||
}} | ||
onMouseDown={handleMouseDown} | ||
> | ||
<Button | ||
kind="ghost" | ||
data-testid={`dashboard-list-item-${tab.key ?? ''}-button`} | ||
onClick={() => handleTabSelect(tab)} | ||
className={focusedIndex === index ? 'focused' : ''} | ||
gzh2003 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
style={{ transition: 'none' }} | ||
> | ||
{tab.icon ? ( | ||
<span className="dashboard-list-item-icon"> | ||
{React.isValidElement(tab.icon) ? ( | ||
tab.icon | ||
) : ( | ||
<FontAwesomeIcon icon={tab.icon as IconDefinition} /> | ||
)} | ||
</span> | ||
) : null} | ||
{tab.title} | ||
</Button> | ||
</li> | ||
)), | ||
[filteredTabs, handleTabSelect, focusedIndex, handleMouseDown] | ||
); | ||
|
||
const errorElement = useMemo( | ||
() => | ||
tabElements.length === 0 ? <span>No open dashboard found.</span> : null, | ||
[tabElements] | ||
); | ||
|
||
return ( | ||
<div className="dashboard-list-container d-flex flex-column"> | ||
<div className="dashboard-list-header"> | ||
<SearchInput | ||
value={searchText} | ||
placeholder="Find open dashboard" | ||
endPlaceholder={GLOBAL_SHORTCUTS.OPEN_DASHBOARD_LIST.getDisplayText()} | ||
onChange={handleSearchChange} | ||
onKeyDown={handleSearchKeyDown} | ||
ref={searchField} | ||
/> | ||
</div> | ||
<ul className="dashboard-list flex-grow-1" ref={listRef}> | ||
{errorElement && ( | ||
<li className="dashboard-list-message">{errorElement}</li> | ||
)} | ||
{!errorElement && tabElements} | ||
</ul> | ||
</div> | ||
); | ||
} | ||
|
||
export default DashboardList; |
Uh oh!
There was an error while loading. Please reload this page.