Skip to content

Commit 1d3ddff

Browse files
authored
feat: DH-19722: Better handling for large numbers of open dashboards (#2481)
For DH-19722. This PR adds a popper for searching and filtering through dashboards. It supports keyboard navigation and includes a global shortcut (`Ctrl+Shift+D` / `Cmd+Shift+D`) to toggle the quick filter popper. Changes: - Dashboard search: Filter dashboards by typing in the search input - Global shortcut: Ctrl+Shift+D/Cmd+Shift+D opens the dashboard search popper (only when 2+ dashboards exist) - Keyboard interactions: arrow keys navigate between dashboard items; enter sets the active dashboard to the currently focused item in the list (if no dashboard in the list is focused it will default to the first dashboard in the search) - Adds 'endPlaceholder' to `SearchInput` to display the associated shortcut
1 parent d65ed40 commit 1d3ddff

10 files changed

Lines changed: 307 additions & 5 deletions

File tree

packages/components/src/SearchInput.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
padding-right: 1.75rem; // leave space for search icon and cancel button from browser
88
}
99

10-
.search-icon {
10+
.search-end-content {
1111
color: var(--dh-color-search-icon);
1212
pointer-events: none;
1313
position: absolute;
@@ -17,6 +17,11 @@
1717
height: 100%;
1818
display: flex;
1919
align-items: center;
20+
gap: $spacer-1;
21+
}
22+
23+
.search-end-placeholder {
24+
color: var(--dh-color-text-disabled);
2025
}
2126

2227
::-webkit-search-cancel-button {

packages/components/src/SearchInput.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import { ContextActions } from './context-actions';
1010
interface SearchInputProps {
1111
value: string;
1212
placeholder: string;
13+
/** Placeholder text shown on the right side of the input when empty */
14+
endPlaceholder?: string;
1315
onBlur?: React.FocusEventHandler<HTMLInputElement>;
1416
onChange: React.ChangeEventHandler<HTMLInputElement>;
1517
onKeyDown: React.KeyboardEventHandler<HTMLInputElement>;
1618
className: string;
1719
disabled?: boolean;
20+
/** Number of search matches, displayed at the end of the search field. */
1821
matchCount: number;
1922
id: string;
2023
'data-testid'?: string;
@@ -76,6 +79,7 @@ class SearchInput extends PureComponent<SearchInputProps> {
7679
const {
7780
value,
7881
placeholder,
82+
endPlaceholder,
7983
onBlur,
8084
onChange,
8185
className,
@@ -161,7 +165,10 @@ class SearchInput extends PureComponent<SearchInputProps> {
161165
<ContextActions actions={contextActions} />
162166
</>
163167
) : (
164-
<span className="search-icon">
168+
<span className="search-end-content">
169+
{(endPlaceholder ?? '') !== '' && value === '' && (
170+
<span className="search-end-placeholder">{endPlaceholder}</span>
171+
)}
165172
<FontAwesomeIcon icon={vsSearch} />
166173
</span>
167174
)}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
@import '@deephaven/components/scss/custom.scss';
2+
3+
$dashboard-list-color: $gray-200;
4+
$dashboard-list-hover-color: $foreground;
5+
$dashboard-list-background-hover-color: var(--dh-color-highlight-hover);
6+
7+
$dashboard-list-owner-color: $gray-400;
8+
$dashboard-list-owner-hover-color: $gray-200;
9+
10+
.dashboard-list-container {
11+
height: 25.6rem;
12+
padding: 0 0.5rem;
13+
text-align: left;
14+
width: 23rem;
15+
16+
&:focus {
17+
outline: none;
18+
}
19+
20+
.dashboard-list {
21+
margin: 0.5rem -0.5rem 0;
22+
padding: 0;
23+
text-align: left;
24+
overflow: auto;
25+
li {
26+
list-style: none;
27+
padding: 0;
28+
margin: 0;
29+
}
30+
.btn {
31+
border: none;
32+
border-radius: 0;
33+
display: block;
34+
text-align: left;
35+
margin: 0;
36+
padding: 0.35rem 0.5rem;
37+
width: 100%;
38+
color: $dashboard-list-color;
39+
&:hover,
40+
&:focus,
41+
&.focused {
42+
color: $dashboard-list-hover-color;
43+
background-color: $dashboard-list-background-hover-color;
44+
text-decoration: none;
45+
}
46+
}
47+
.dashboard-list-item-icon {
48+
margin-right: $spacer-2;
49+
}
50+
.dashboard-list-message {
51+
padding: 0 $spacer-2;
52+
}
53+
}
54+
55+
.dashboard-list-header {
56+
padding-top: $spacer-2;
57+
padding-bottom: $spacer-2;
58+
59+
.btn .fa-md {
60+
margin-right: $spacer-2;
61+
}
62+
}
63+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React, {
2+
type ChangeEvent,
3+
useCallback,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
10+
import { type IconDefinition } from '@fortawesome/fontawesome-svg-core';
11+
import { EMPTY_ARRAY } from '@deephaven/utils';
12+
import { Button } from '../Button';
13+
import SearchInput from '../SearchInput';
14+
import type { NavTabItem } from './NavTabList';
15+
import './DashboardList.scss';
16+
import { GLOBAL_SHORTCUTS } from '../shortcuts';
17+
18+
export interface DashboardListProps {
19+
onSelect: (tab: NavTabItem) => void;
20+
tabs?: NavTabItem[];
21+
}
22+
23+
/**
24+
* Display a search field and a list of dashboard tabs
25+
* @param props The tabs and handlers to use for this list
26+
* @returns A JSX element for the list of dashboard tabs, along with search
27+
*/
28+
export function DashboardList(props: DashboardListProps): JSX.Element {
29+
const { onSelect, tabs = EMPTY_ARRAY } = props;
30+
const [searchText, setSearchText] = useState('');
31+
const [focusedIndex, setFocusedIndex] = useState(0);
32+
const searchField = useRef<SearchInput>(null);
33+
const listRef = useRef<HTMLUListElement>(null);
34+
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
35+
36+
useEffect(() => {
37+
searchField.current?.focus();
38+
}, []);
39+
40+
useEffect(() => {
41+
setFocusedIndex(0);
42+
}, [searchText]);
43+
44+
useEffect(() => {
45+
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
46+
itemRefs.current[focusedIndex]?.scrollIntoView({
47+
behavior: 'auto',
48+
block: 'nearest',
49+
});
50+
}
51+
}, [focusedIndex]);
52+
53+
const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
54+
setSearchText(e.target.value);
55+
}, []);
56+
57+
const handleTabSelect = useCallback(
58+
(tab: NavTabItem) => {
59+
onSelect(tab);
60+
},
61+
[onSelect]
62+
);
63+
64+
const handleMouseDown = useCallback((event: React.MouseEvent) => {
65+
// Prevent mousedown from taking focus away from the search input
66+
event.preventDefault();
67+
}, []);
68+
69+
const filteredTabs = useMemo(
70+
() =>
71+
tabs.filter(tab =>
72+
tab.title.toLowerCase().includes(searchText.toLowerCase())
73+
),
74+
[searchText, tabs]
75+
).sort((a, b) => a.title.localeCompare(b.title) ?? 0);
76+
77+
const handleSearchKeyDown = useCallback(
78+
(event: React.KeyboardEvent) => {
79+
if (event.key === 'ArrowDown' && filteredTabs.length > 0) {
80+
event.preventDefault();
81+
setFocusedIndex(prev =>
82+
prev === -1 ? 0 : (prev + 1) % filteredTabs.length
83+
);
84+
} else if (event.key === 'ArrowUp' && filteredTabs.length > 0) {
85+
event.preventDefault();
86+
setFocusedIndex(prev => {
87+
if (prev === -1) return filteredTabs.length - 1;
88+
return (prev - 1 + filteredTabs.length) % filteredTabs.length;
89+
});
90+
} else if (event.key === 'Enter' && filteredTabs.length > 0) {
91+
event.preventDefault();
92+
const selectedIndex = focusedIndex >= 0 ? focusedIndex : 0;
93+
handleTabSelect(filteredTabs[selectedIndex]);
94+
} else if (event.key === 'Tab') {
95+
event.preventDefault();
96+
}
97+
},
98+
[filteredTabs, focusedIndex, handleTabSelect]
99+
);
100+
101+
const tabElements = useMemo(
102+
() =>
103+
filteredTabs.map((tab, index) => (
104+
<li
105+
key={tab.key}
106+
ref={(el: HTMLLIElement | null) => {
107+
itemRefs.current[index] = el;
108+
}}
109+
onMouseDown={handleMouseDown}
110+
>
111+
<Button
112+
kind="ghost"
113+
data-testid={`dashboard-list-item-${tab.key ?? ''}-button`}
114+
onClick={() => handleTabSelect(tab)}
115+
className={focusedIndex === index ? 'focused' : ''}
116+
style={{ transition: 'none' }}
117+
>
118+
{tab.icon ? (
119+
<span className="dashboard-list-item-icon">
120+
{React.isValidElement(tab.icon) ? (
121+
tab.icon
122+
) : (
123+
<FontAwesomeIcon icon={tab.icon as IconDefinition} />
124+
)}
125+
</span>
126+
) : null}
127+
{tab.title}
128+
</Button>
129+
</li>
130+
)),
131+
[filteredTabs, handleTabSelect, focusedIndex, handleMouseDown]
132+
);
133+
134+
const errorElement = useMemo(
135+
() =>
136+
tabElements.length === 0 ? <span>No open dashboard found.</span> : null,
137+
[tabElements]
138+
);
139+
140+
return (
141+
<div className="dashboard-list-container d-flex flex-column">
142+
<div className="dashboard-list-header">
143+
<SearchInput
144+
value={searchText}
145+
placeholder="Find open dashboard"
146+
endPlaceholder={GLOBAL_SHORTCUTS.OPEN_DASHBOARD_LIST.getDisplayText()}
147+
onChange={handleSearchChange}
148+
onKeyDown={handleSearchKeyDown}
149+
ref={searchField}
150+
/>
151+
</div>
152+
<ul className="dashboard-list flex-grow-1" ref={listRef}>
153+
{errorElement && (
154+
<li className="dashboard-list-message">{errorElement}</li>
155+
)}
156+
{!errorElement && tabElements}
157+
</ul>
158+
</div>
159+
);
160+
}
161+
162+
export default DashboardList;

packages/components/src/navigation/NavTabList.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from 'react-beautiful-dnd';
1515
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
1616
import { type IconDefinition } from '@fortawesome/fontawesome-svg-core';
17-
import { vsChevronRight, vsChevronLeft } from '@deephaven/icons';
17+
import { vsChevronRight, vsChevronLeft, vsChevronDown } from '@deephaven/icons';
1818
import { useResizeObserver } from '@deephaven/react-hooks';
1919
import DragUtils from '../DragUtils';
2020
import Button from '../Button';
@@ -23,7 +23,11 @@ import './NavTabList.scss';
2323
import {
2424
type ContextAction,
2525
type ResolvableContextAction,
26+
ContextActions,
2627
} from '../context-actions';
28+
import Popper from '../popper/Popper';
29+
import DashboardList from './DashboardList';
30+
import { GLOBAL_SHORTCUTS } from '../shortcuts';
2731

2832
// mouse hold timeout to act as hold instead of click
2933
const CLICK_TIMEOUT = 500;
@@ -110,8 +114,9 @@ function isScrolledLeft(element: HTMLElement): boolean {
110114

111115
function isScrolledRight(element: HTMLElement): boolean {
112116
return (
113-
element.scrollLeft + element.clientWidth === element.scrollWidth ||
114-
element.scrollWidth === 0
117+
// single pixel buffer to account for sub-pixel rendering
118+
Math.abs(element.scrollLeft + element.clientWidth - element.scrollWidth) <=
119+
1 || element.scrollWidth === 0
115120
);
116121
}
117122

@@ -179,8 +184,10 @@ function NavTabList({
179184
}: NavTabListProps): React.ReactElement {
180185
const containerRef = useRef<HTMLDivElement>();
181186
const [isOverflowing, setIsOverflowing] = useState(true);
187+
const [isDashboardTabMenuShown, setIsDashboardTabMenuShown] = useState(false);
182188
const [disableScrollLeft, setDisableScrollLeft] = useState(true);
183189
const [disableScrollRight, setDisableScrollRight] = useState(true);
190+
184191
const handleResize = useCallback(() => {
185192
if (containerRef.current == null) {
186193
return;
@@ -441,6 +448,20 @@ function NavTabList({
441448
[activeKey]
442449
);
443450

451+
const handleDashboardMenuClick = () => {
452+
setIsDashboardTabMenuShown(!isDashboardTabMenuShown);
453+
};
454+
455+
const handleDashboardMenuSelect = (tab: NavTabItem) => {
456+
setIsDashboardTabMenuShown(false);
457+
458+
onSelect(tab.key);
459+
};
460+
461+
const handleDashboardMenuClose = () => {
462+
setIsDashboardTabMenuShown(false);
463+
};
464+
444465
return (
445466
<nav className="nav-container">
446467
{isOverflowing && (
@@ -496,6 +517,42 @@ function NavTabList({
496517
disabled={disableScrollRight}
497518
/>
498519
)}
520+
<Button
521+
kind="ghost"
522+
icon={<FontAwesomeIcon icon={vsChevronDown} transform="grow-4" />}
523+
className="btn-dashboard-list-menu btn-show-dashboard-list"
524+
tooltip="Search open dashboards"
525+
onClick={handleDashboardMenuClick}
526+
disabled={tabs.length < 2}
527+
style={{
528+
visibility: isOverflowing ? 'visible' : 'hidden',
529+
marginLeft: 'auto',
530+
}}
531+
>
532+
<Popper
533+
isShown={isDashboardTabMenuShown}
534+
className="dashboard-list-menu-popper"
535+
onExited={handleDashboardMenuClose}
536+
options={{
537+
placement: 'bottom-start',
538+
}}
539+
closeOnBlur
540+
interactive
541+
>
542+
<DashboardList tabs={tabs} onSelect={handleDashboardMenuSelect} />
543+
</Popper>
544+
</Button>
545+
<ContextActions
546+
actions={[
547+
{
548+
action: () => {
549+
setIsDashboardTabMenuShown(!isDashboardTabMenuShown);
550+
},
551+
shortcut: GLOBAL_SHORTCUTS.OPEN_DASHBOARD_LIST,
552+
isGlobal: true,
553+
},
554+
]}
555+
/>
499556
</nav>
500557
);
501558
}

packages/components/src/navigation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { default as MenuItem } from './MenuItem';
44
export type { SwitchMenuItemDef, MenuItemDef, MenuItemProps } from './MenuItem';
55
export { default as NavTabList } from './NavTabList';
66
export type { NavTabItem } from './NavTabList';
7+
export { default as DashboardList } from './DashboardList';
78
export { default as Page } from './Page';
89
export type { PageProps } from './Page';
910
export { default as Stack } from './Stack';

0 commit comments

Comments
 (0)