Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 6 additions & 1 deletion packages/components/src/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ContextActions } from './context-actions';
interface SearchInputProps {
value: string;
placeholder: string;
endPlaceholder?: string;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onChange: React.ChangeEventHandler<HTMLInputElement>;
onKeyDown: React.KeyboardEventHandler<HTMLInputElement>;
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;
}
}
}
156 changes: 156 additions & 0 deletions packages/components/src/navigation/DashboardList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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 { 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 = [] } = 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(() => {
if (searchField.current) {
searchField.current.focus();
}
}, []);

useEffect(() => {
setFocusedIndex(0);
}, [searchText]);

useEffect(() => {
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
itemRefs.current[focusedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [focusedIndex]);

const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
}, []);

const handleTabSelect = useCallback(
(tab: NavTabItem) => {
onSelect(tab);
},
[onSelect]
);

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;
}}
>
<Button
kind="ghost"
data-testid={`dashboard-list-item-${tab.key ?? ''}-button`}
onClick={() => handleTabSelect(tab)}
className={focusedIndex === index ? 'focused' : ''}
>
{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]
);

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_SEARCH_MENU.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_SEARCH_MENU,
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';
7 changes: 7 additions & 0 deletions packages/components/src/shortcuts/GlobalShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ const GLOBAL_SHORTCUTS = {
macShortcut: [MODIFIER.CMD, MODIFIER.OPTION, MODIFIER.SHIFT, KEY.L],
isEditable: true,
}),
OPEN_DASHBOARD_SEARCH_MENU: ShortcutRegistry.createAndAdd({
id: 'GLOBAL.OPEN_DASHBOARD_SEARCH_MENU',
name: 'Open Dashboard Search Menu',
shortcut: [MODIFIER.CTRL, MODIFIER.SHIFT, KEY.D],
macShortcut: [MODIFIER.CMD, MODIFIER.SHIFT, KEY.D],
isEditable: true,
}),
NEXT: ShortcutRegistry.createAndAdd({
id: 'GLOBAL.NEXT',
name: 'Next',
Expand Down
Binary file modified tests/styleguide.spec.ts-snapshots/navigations-chromium-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/styleguide.spec.ts-snapshots/navigations-firefox-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/styleguide.spec.ts-snapshots/navigations-webkit-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading