Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/components/src/SearchInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
padding-right: 1.75rem; // leave space for search icon and cancel button from browser
}

.search-icon {
.search-end-content {
color: var(--dh-color-search-icon);
pointer-events: none;
position: absolute;
Expand All @@ -17,6 +17,11 @@
height: 100%;
display: flex;
align-items: center;
gap: $spacer-1;
}

.search-end-placeholder {
color: var(--dh-color-text-disabled);
}

::-webkit-search-cancel-button {
Expand Down
9 changes: 7 additions & 2 deletions packages/components/src/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { ContextActions } from './context-actions';
interface SearchInputProps {
value: string;
placeholder: string;
endPlaceholder?: string; // Optional placeholder text shown on the right side of the input when empty
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onChange: React.ChangeEventHandler<HTMLInputElement>;
onKeyDown: React.KeyboardEventHandler<HTMLInputElement>;
className: string;
disabled?: boolean;
matchCount: number;
matchCount: number; // Number of search matches
id: string;
'data-testid'?: string;
cursor?: {
Expand Down Expand Up @@ -76,6 +77,7 @@ class SearchInput extends PureComponent<SearchInputProps> {
const {
value,
placeholder,
endPlaceholder,
onBlur,
onChange,
className,
Expand Down Expand Up @@ -161,7 +163,10 @@ class SearchInput extends PureComponent<SearchInputProps> {
<ContextActions actions={contextActions} />
</>
) : (
<span className="search-icon">
<span className="search-end-content">
{(endPlaceholder ?? '') !== '' && value === '' && (
<span className="search-end-placeholder">{endPlaceholder}</span>
)}
<FontAwesomeIcon icon={vsSearch} />
</span>
)}
Expand Down
63 changes: 63 additions & 0 deletions packages/components/src/navigation/DashboardList.scss
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;
}
}
}
162 changes: 162 additions & 0 deletions packages/components/src/navigation/DashboardList.tsx
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
Copy link
Contributor Author

@gzh2003 gzh2003 Jul 16, 2025

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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.
In another scenario, eating just an mouseup event could screw up anything that had started listening for mousedown and then expected a mouseup... Capturing down is a little less dangerous though.

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' : ''}
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;
63 changes: 60 additions & 3 deletions packages/components/src/navigation/NavTabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from 'react-beautiful-dnd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { type IconDefinition } from '@fortawesome/fontawesome-svg-core';
import { vsChevronRight, vsChevronLeft } from '@deephaven/icons';
import { vsChevronRight, vsChevronLeft, vsChevronDown } from '@deephaven/icons';
import { useResizeObserver } from '@deephaven/react-hooks';
import DragUtils from '../DragUtils';
import Button from '../Button';
Expand All @@ -23,7 +23,11 @@ import './NavTabList.scss';
import {
type ContextAction,
type ResolvableContextAction,
ContextActions,
} from '../context-actions';
import Popper from '../popper/Popper';
import DashboardList from './DashboardList';
import { GLOBAL_SHORTCUTS } from '../shortcuts';

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

function isScrolledRight(element: HTMLElement): boolean {
return (
element.scrollLeft + element.clientWidth === element.scrollWidth ||
element.scrollWidth === 0
// single pixel buffer to account for sub-pixel rendering
Math.abs(element.scrollLeft + element.clientWidth - element.scrollWidth) <=
1 || element.scrollWidth === 0
);
}

Expand Down Expand Up @@ -179,8 +184,10 @@ function NavTabList({
}: NavTabListProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>();
const [isOverflowing, setIsOverflowing] = useState(true);
const [isDashboardTabMenuShown, setIsDashboardTabMenuShown] = useState(false);
const [disableScrollLeft, setDisableScrollLeft] = useState(true);
const [disableScrollRight, setDisableScrollRight] = useState(true);

const handleResize = useCallback(() => {
if (containerRef.current == null) {
return;
Expand Down Expand Up @@ -441,6 +448,20 @@ function NavTabList({
[activeKey]
);

const handleDashboardMenuClick = () => {
setIsDashboardTabMenuShown(!isDashboardTabMenuShown);
};

const handleDashboardMenuSelect = (tab: NavTabItem) => {
setIsDashboardTabMenuShown(false);

onSelect(tab.key);
};

const handleDashboardMenuClose = () => {
setIsDashboardTabMenuShown(false);
};

return (
<nav className="nav-container">
{isOverflowing && (
Expand Down Expand Up @@ -496,6 +517,42 @@ function NavTabList({
disabled={disableScrollRight}
/>
)}
<Button
kind="ghost"
icon={<FontAwesomeIcon icon={vsChevronDown} transform="grow-4" />}
className="btn-dashboard-list-menu btn-show-dashboard-list"
tooltip="Search open dashboards"
onClick={handleDashboardMenuClick}
disabled={tabs.length < 2}
style={{
visibility: isOverflowing ? 'visible' : 'hidden',
marginLeft: 'auto',
}}
>
<Popper
isShown={isDashboardTabMenuShown}
className="dashboard-list-menu-popper"
onExited={handleDashboardMenuClose}
options={{
placement: 'bottom-start',
}}
closeOnBlur
interactive
>
<DashboardList tabs={tabs} onSelect={handleDashboardMenuSelect} />
</Popper>
</Button>
<ContextActions
actions={[
{
action: () => {
setIsDashboardTabMenuShown(!isDashboardTabMenuShown);
},
shortcut: GLOBAL_SHORTCUTS.OPEN_DASHBOARD_LIST,
isGlobal: true,
},
]}
/>
</nav>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { default as MenuItem } from './MenuItem';
export type { SwitchMenuItemDef, MenuItemDef, MenuItemProps } from './MenuItem';
export { default as NavTabList } from './NavTabList';
export type { NavTabItem } from './NavTabList';
export { default as DashboardList } from './DashboardList';
export { default as Page } from './Page';
export type { PageProps } from './Page';
export { default as Stack } from './Stack';
Loading
Loading