Skip to content

Commit c9e8783

Browse files
committed
Create dropdown to filter dashboards
1 parent e32b68f commit c9e8783

7 files changed

Lines changed: 299 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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ContextActions } from './context-actions';
1010
interface SearchInputProps {
1111
value: string;
1212
placeholder: string;
13+
endPlaceholder?: string;
1314
onBlur?: React.FocusEventHandler<HTMLInputElement>;
1415
onChange: React.ChangeEventHandler<HTMLInputElement>;
1516
onKeyDown: React.KeyboardEventHandler<HTMLInputElement>;
@@ -76,6 +77,7 @@ class SearchInput extends PureComponent<SearchInputProps> {
7677
const {
7778
value,
7879
placeholder,
80+
endPlaceholder,
7981
onBlur,
8082
onChange,
8183
className,
@@ -161,7 +163,10 @@ class SearchInput extends PureComponent<SearchInputProps> {
161163
<ContextActions actions={contextActions} />
162164
</>
163165
) : (
164-
<span className="search-icon">
166+
<span className="search-end-content">
167+
{(endPlaceholder ?? '') !== '' && value === '' && (
168+
<span className="search-end-placeholder">{endPlaceholder}</span>
169+
)}
165170
<FontAwesomeIcon icon={vsSearch} />
166171
</span>
167172
)}
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: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 { Button } from '../Button';
12+
import SearchInput from '../SearchInput';
13+
import type { NavTabItem } from './NavTabList';
14+
import './DashboardList.scss';
15+
import { GLOBAL_SHORTCUTS } from '../shortcuts';
16+
17+
export interface DashboardListProps {
18+
onSelect: (tab: NavTabItem) => void;
19+
tabs?: NavTabItem[];
20+
}
21+
22+
/**
23+
* Display a search field and a list of dashboard tabs
24+
* @param props The tabs and handlers to use for this list
25+
* @returns A JSX element for the list of dashboard tabs, along with search
26+
*/
27+
export function DashboardList(props: DashboardListProps): JSX.Element {
28+
const { onSelect, tabs = [] } = props;
29+
const [searchText, setSearchText] = useState('');
30+
const [focusedIndex, setFocusedIndex] = useState(0);
31+
const searchField = useRef<SearchInput>(null);
32+
const listRef = useRef<HTMLUListElement>(null);
33+
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
34+
35+
useEffect(() => {
36+
if (searchField.current) {
37+
searchField.current.focus();
38+
}
39+
}, []);
40+
41+
useEffect(() => {
42+
setFocusedIndex(0);
43+
}, [searchText]);
44+
45+
useEffect(() => {
46+
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
47+
itemRefs.current[focusedIndex]?.scrollIntoView({
48+
behavior: 'smooth',
49+
block: 'nearest',
50+
});
51+
}
52+
}, [focusedIndex]);
53+
54+
const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
55+
setSearchText(e.target.value);
56+
}, []);
57+
58+
const handleTabSelect = useCallback(
59+
(tab: NavTabItem) => {
60+
onSelect(tab);
61+
},
62+
[onSelect]
63+
);
64+
65+
const filteredTabs = useMemo(
66+
() =>
67+
tabs.filter(tab =>
68+
tab.title.toLowerCase().includes(searchText.toLowerCase())
69+
),
70+
[searchText, tabs]
71+
).sort((a, b) => a.title.localeCompare(b.title) ?? 0);
72+
73+
const handleSearchKeyDown = useCallback(
74+
(event: React.KeyboardEvent) => {
75+
if (event.key === 'ArrowDown' && filteredTabs.length > 0) {
76+
event.preventDefault();
77+
setFocusedIndex(prev =>
78+
prev === -1 ? 0 : (prev + 1) % filteredTabs.length
79+
);
80+
} else if (event.key === 'ArrowUp' && filteredTabs.length > 0) {
81+
event.preventDefault();
82+
setFocusedIndex(prev => {
83+
if (prev === -1) return filteredTabs.length - 1;
84+
return (prev - 1 + filteredTabs.length) % filteredTabs.length;
85+
});
86+
} else if (event.key === 'Enter' && filteredTabs.length > 0) {
87+
event.preventDefault();
88+
const selectedIndex = focusedIndex >= 0 ? focusedIndex : 0;
89+
handleTabSelect(filteredTabs[selectedIndex]);
90+
} else if (event.key === 'Tab') {
91+
event.preventDefault();
92+
}
93+
},
94+
[filteredTabs, focusedIndex, handleTabSelect]
95+
);
96+
97+
const tabElements = useMemo(
98+
() =>
99+
filteredTabs.map((tab, index) => (
100+
<li
101+
key={tab.key}
102+
ref={(el: HTMLLIElement | null) => {
103+
itemRefs.current[index] = el;
104+
}}
105+
>
106+
<Button
107+
kind="ghost"
108+
data-testid={`dashboard-list-item-${tab.key ?? ''}-button`}
109+
onClick={() => handleTabSelect(tab)}
110+
className={focusedIndex === index ? 'focused' : ''}
111+
>
112+
{tab.icon ? (
113+
<span className="dashboard-list-item-icon">
114+
{React.isValidElement(tab.icon) ? (
115+
tab.icon
116+
) : (
117+
<FontAwesomeIcon icon={tab.icon as IconDefinition} />
118+
)}
119+
</span>
120+
) : null}
121+
{tab.title}
122+
</Button>
123+
</li>
124+
)),
125+
[filteredTabs, handleTabSelect, focusedIndex]
126+
);
127+
128+
const errorElement = useMemo(
129+
() =>
130+
tabElements.length === 0 ? <span>No open dashboard found.</span> : null,
131+
[tabElements]
132+
);
133+
134+
return (
135+
<div className="dashboard-list-container d-flex flex-column">
136+
<div className="dashboard-list-header">
137+
<SearchInput
138+
value={searchText}
139+
placeholder="Find open dashboard"
140+
endPlaceholder={GLOBAL_SHORTCUTS.OPEN_DASHBOARD_SEARCH_MENU.getDisplayText()}
141+
onChange={handleSearchChange}
142+
onKeyDown={handleSearchKeyDown}
143+
ref={searchField}
144+
/>
145+
</div>
146+
<ul className="dashboard-list flex-grow-1" ref={listRef}>
147+
{errorElement && (
148+
<li className="dashboard-list-message">{errorElement}</li>
149+
)}
150+
{!errorElement && tabElements}
151+
</ul>
152+
</div>
153+
);
154+
}
155+
156+
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,30 @@ 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+
465+
const contextActions = [
466+
{
467+
action: () => {
468+
setIsDashboardTabMenuShown(!isDashboardTabMenuShown);
469+
},
470+
shortcut: GLOBAL_SHORTCUTS.OPEN_DASHBOARD_SEARCH_MENU,
471+
isGlobal: true,
472+
},
473+
];
474+
444475
return (
445476
<nav className="nav-container">
446477
{isOverflowing && (
@@ -496,6 +527,32 @@ function NavTabList({
496527
disabled={disableScrollRight}
497528
/>
498529
)}
530+
<Button
531+
kind="ghost"
532+
icon={<FontAwesomeIcon icon={vsChevronDown} transform="grow-4" />}
533+
className="btn-dashboard-list-menu btn-show-dashboard-list"
534+
tooltip="Search open dashboards"
535+
onClick={handleDashboardMenuClick}
536+
disabled={tabs.length < 2}
537+
style={{
538+
visibility: isOverflowing ? 'visible' : 'hidden',
539+
marginLeft: 'auto',
540+
}}
541+
>
542+
<Popper
543+
isShown={isDashboardTabMenuShown}
544+
className="dashboard-list-menu-popper"
545+
onExited={handleDashboardMenuClose}
546+
options={{
547+
placement: 'bottom-start',
548+
}}
549+
closeOnBlur
550+
interactive
551+
>
552+
<DashboardList tabs={tabs} onSelect={handleDashboardMenuSelect} />
553+
</Popper>
554+
</Button>
555+
<ContextActions actions={contextActions} />
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';

packages/components/src/shortcuts/GlobalShortcuts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ const GLOBAL_SHORTCUTS = {
6565
macShortcut: [MODIFIER.CMD, MODIFIER.OPTION, MODIFIER.SHIFT, KEY.L],
6666
isEditable: true,
6767
}),
68+
OPEN_DASHBOARD_SEARCH_MENU: ShortcutRegistry.createAndAdd({
69+
id: 'GLOBAL.OPEN_DASHBOARD_SEARCH_MENU',
70+
name: 'Open Dashboard Search Menu',
71+
shortcut: [MODIFIER.CTRL, MODIFIER.SHIFT, KEY.D],
72+
macShortcut: [MODIFIER.CMD, MODIFIER.SHIFT, KEY.D],
73+
isEditable: true,
74+
}),
6875
NEXT: ShortcutRegistry.createAndAdd({
6976
id: 'GLOBAL.NEXT',
7077
name: 'Next',

0 commit comments

Comments
 (0)