{
+ (e.target as HTMLDivElement).focus();
+ // focus is normally set on mousedown, but dnd calls preventDefault for drag purposes
+ // so we can call focus on the firing of the actual click event manually
+
+ onSelect(key);
+ }}
+ onKeyPress={event => {
+ if (event.key === 'Enter') onSelect(key);
+ }}
+ >
+ {title}
+ {isClosable && (
+
+
+
+ )}
+
+ );
+ }
+);
+
+NavTab.displayName = 'NavTab';
+
+export default NavTab;
diff --git a/packages/components/src/navigation/NavTabList.scss b/packages/components/src/navigation/NavTabList.scss
new file mode 100644
index 0000000000..af53828af4
--- /dev/null
+++ b/packages/components/src/navigation/NavTabList.scss
@@ -0,0 +1,164 @@
+@import '../../scss/custom.scss';
+
+$tab-height: 32px;
+$tab-drag-border-width: 1px;
+$tab-font-size: 1rem;
+
+$tab-link-side-padding: 24px;
+$tab-link-underline-spacing: 6px;
+
+$tab-link-hover-underline-color: $gray-400;
+
+$tab-link-active-color: $gray-200;
+$tab-link-active-underline-color: var(--dh-color-accent-bg);
+
+$tab-link-active-hover-underline-color: var(--dh-color-accent-bg);
+
+$tab-button-hover-color: $gray-200;
+$tab-button-separator-color: $gray-600;
+
+$tab-control-btn-offset: -8px;
+
+.nav-container {
+ display: flex;
+ flex-shrink: 0;
+
+ .nav-tabs {
+ border: none;
+ height: $tab-height;
+ font-size: $tab-font-size;
+ flex-wrap: nowrap;
+ overflow-x: hidden;
+ position: relative;
+
+ &.dragging {
+ @include ants-base($gray-300, $background);
+ }
+
+ .btn-nav-tab {
+ line-height: $tab-height - $tab-drag-border-width * 2; // subtract top and bottom borders, and focus border
+ width: auto;
+ max-width: 200px;
+ overflow: hidden;
+ padding: 0 $tab-link-side-padding;
+ position: relative;
+ text-overflow: ellipsis;
+ user-select: none;
+ white-space: nowrap;
+ flex-shrink: 0;
+ background: none;
+ background-clip: padding-box;
+
+ .btn-nav-tab-close {
+ position: absolute;
+ height: 20px;
+ line-height: $tab-font-size;
+ right: 0.25rem;
+ bottom: 6px;
+ padding: 2px 1px;
+ opacity: 0;
+ transition: opacity $transition ease-out;
+
+ &:hover {
+ color: $tab-button-hover-color;
+ }
+
+ &:focus {
+ opacity: 1;
+ color: $tab-button-hover-color;
+ }
+ }
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ height: 1px;
+ left: $tab-link-side-padding;
+ right: $tab-link-side-padding;
+ bottom: $tab-link-underline-spacing;
+ transition: all $transition-mid ease-out;
+ }
+
+ //hover line is drawn as a before element
+ &::before {
+ background: transparent;
+ transform: translateY($tab-link-underline-spacing);
+ }
+
+ //active is drawn animated overtop as after element
+ &::after {
+ background: $tab-link-active-underline-color;
+ transform: scaleX(0);
+ }
+
+ &:focus {
+ // these seem like something that shouldn't have a regular focus state
+ box-shadow: none;
+ border-color: transparent;
+ &::before {
+ box-shadow: 0 1px 0 1px $input-btn-focus-color;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+
+ .btn-nav-tab-close {
+ opacity: 1;
+ }
+
+ &::before {
+ background: $tab-link-hover-underline-color;
+ transform: translateY(0);
+ }
+ }
+
+ &.active {
+ color: $tab-link-active-color;
+
+ .btn-nav-tab-close {
+ opacity: 1;
+ }
+
+ &::after {
+ transform: scaleX(1);
+ }
+ &::before {
+ transform: translateY(0);
+ }
+ }
+
+ &.dragging {
+ color: $tab-link-active-color;
+ background-color: var(--dh-color-accent-down-bg);
+
+ .btn-nav-tab-close {
+ opacity: 0;
+ }
+
+ &::before,
+ &::after {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .tab-controls-btn {
+ border-radius: $border-radius;
+ width: 25px;
+ padding: 0;
+ flex-shrink: 0;
+ z-index: 2;
+ }
+
+ .tab-controls-btn-left {
+ margin-right: $tab-control-btn-offset;
+ }
+
+ .tab-controls-btn-right {
+ margin-left: $tab-control-btn-offset;
+ }
+}
diff --git a/packages/components/src/navigation/NavTabList.tsx b/packages/components/src/navigation/NavTabList.tsx
new file mode 100644
index 0000000000..1f56a86f37
--- /dev/null
+++ b/packages/components/src/navigation/NavTabList.tsx
@@ -0,0 +1,494 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+import clamp from 'lodash.clamp';
+import {
+ DragDropContext,
+ Droppable,
+ OnDragEndResponder,
+} from 'react-beautiful-dnd';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { vsChevronRight, vsChevronLeft } from '@deephaven/icons';
+import { useResizeObserver } from '@deephaven/react-hooks';
+import DragUtils from '../DragUtils';
+import Button from '../Button';
+import NavTab from './NavTab';
+import './NavTabList.scss';
+import { ContextAction, ResolvableContextAction } from '../context-actions';
+
+// mouse hold timeout to act as hold instead of click
+const CLICK_TIMEOUT = 500;
+
+// mouse hold acceleration
+const START_SPEED = 0.01;
+const ACCELERATION = 0.0005;
+
+export interface NavTabItem {
+ /**
+ * Unique key for the tab.
+ */
+ key: string;
+
+ /**
+ * Title to display on the tab.
+ */
+ title: string;
+
+ /**
+ * Whether the tab is closable.
+ * If omitted, the tab will be closeable if onClose exists.
+ */
+ isClosable?: boolean;
+}
+
+type NavTabListProps = {
+ /**
+ * The key of the active tab.
+ * If this does not match a tab key, no tab will be active.
+ */
+ activeKey: string;
+
+ /**
+ * Array of tabs to display.
+ * @see {@link NavTabItem} for the minimum required properties.
+ */
+ tabs: T[];
+
+ /**
+ * Function called when a tab is selected.
+ *
+ * @param key The key of the tab to select
+ */
+ onSelect: (key: string) => void;
+
+ /**
+ * Function called when a tab is closed.
+ * If the function is provided, all tabs will be closeable by default.
+ * Tabs may set their own closeable property to override this behavior.
+ *
+ * @param key The key of the tab to close
+ */
+ onClose?: (key: string) => void;
+
+ /**
+ * Function called when a tab is reordered.
+ * If the function is omitted, the tab list will not be reorderable.
+ *
+ * @param sourceIndex Index in the tab list the drag started from
+ * @param destinationIndex Index in the tab list the drag ended at
+ */
+ onReorder?: (sourceIndex: number, destinationIndex: number) => void;
+
+ /**
+ * Context actions to add to the tab in addition to the default actions.
+ * The default actions are Close, Close to the Right, and Close All.
+ * The default actions have a group value of 20.
+ *
+ * @param tab The tab to make context items for
+ * @returns Additional context items for the tab
+ */
+ makeContextActions?: (tab: T) => ContextAction | ContextAction[];
+};
+
+function isScrolledLeft(element: HTMLElement): boolean {
+ return element.scrollLeft === 0;
+}
+
+function isScrolledRight(element: HTMLElement): boolean {
+ return (
+ element.scrollLeft + element.clientWidth === element.scrollWidth ||
+ element.scrollWidth === 0
+ );
+}
+
+function makeBaseContextActions(
+ tab: NavTabItem,
+ tabs: NavTabItem[],
+ onClose: ((key: string) => void) | undefined
+): ContextAction[] {
+ const { isClosable = false, key } = tab;
+ const contextActions: ContextAction[] = [];
+ if (isClosable && onClose != null) {
+ contextActions.push({
+ title: 'Close',
+ order: 10,
+ group: 20,
+ action: () => {
+ onClose(key);
+ },
+ });
+
+ let disabled = true;
+ for (let i = tabs.length - 1; i > 0; i -= 1) {
+ if (tabs[i].key === tab.key) break;
+ if (tabs[i].isClosable === true) {
+ disabled = false;
+ break;
+ }
+ }
+
+ contextActions.push({
+ title: 'Close to the Right',
+ order: 20,
+ group: 20,
+ action: () => {
+ for (let i = tabs.length - 1; i > 0; i -= 1) {
+ if (tabs[i].key === key) break;
+ if (tabs[i].isClosable === true) onClose(tabs[i].key);
+ }
+ },
+ disabled,
+ });
+
+ contextActions.push({
+ title: 'Close All',
+ order: 30,
+ group: 20,
+ action: () => {
+ tabs.forEach(t => {
+ if (t.isClosable === true) onClose(t.key);
+ });
+ },
+ });
+ }
+
+ return contextActions;
+}
+
+function NavTabList({
+ activeKey,
+ tabs,
+ onSelect,
+ onReorder,
+ onClose,
+ makeContextActions,
+}: NavTabListProps): React.ReactElement {
+ const containerRef = useRef();
+ const [isOverflowing, setIsOverflowing] = useState(true);
+ const [disableScrollLeft, setDisableScrollLeft] = useState(true);
+ const [disableScrollRight, setDisableScrollRight] = useState(true);
+ const handleResize = useCallback(() => {
+ if (containerRef.current == null) {
+ return;
+ }
+
+ if (
+ containerRef.current.clientWidth < containerRef.current.scrollWidth &&
+ tabs.length > 0
+ ) {
+ setIsOverflowing(true);
+ } else {
+ setIsOverflowing(false);
+ }
+
+ setDisableScrollLeft(isScrolledLeft(containerRef.current));
+ setDisableScrollRight(isScrolledRight(containerRef.current));
+ }, [tabs]);
+ useResizeObserver(containerRef.current, handleResize);
+
+ const onDragEnd: OnDragEndResponder = useCallback(
+ result => {
+ DragUtils.stopDragging();
+
+ // dropped outside the list
+ if (!result.destination) {
+ return;
+ }
+
+ onReorder?.(result.source.index, result.destination.index);
+ },
+ [onReorder]
+ );
+
+ const handleScroll = useCallback(() => {
+ if (containerRef.current == null) {
+ return;
+ }
+
+ const shouldDisableScrollLeft = isScrolledLeft(containerRef.current);
+ if (shouldDisableScrollLeft !== disableScrollLeft) {
+ setDisableScrollLeft(shouldDisableScrollLeft);
+ }
+
+ const shouldDisableScrollRight = isScrolledRight(containerRef.current);
+ if (shouldDisableScrollRight !== disableScrollRight) {
+ setDisableScrollRight(shouldDisableScrollRight);
+ }
+ }, [disableScrollLeft, disableScrollRight]);
+
+ const continuousScrollRef = useRef<{
+ holdTimer?: number;
+ rAF?: number;
+ cancelClick: boolean;
+ }>({ cancelClick: false });
+
+ const handleLeftClick = useCallback(() => {
+ if (
+ containerRef.current == null ||
+ continuousScrollRef.current.cancelClick
+ ) {
+ return;
+ }
+
+ const { children } = containerRef.current;
+ for (let i = children.length - 1; i >= 0; i -= 1) {
+ const child = children[i] as HTMLElement;
+ // Subtract 5px from left edge to account for rounding of offset values
+ if (child.offsetLeft < containerRef.current.scrollLeft - 5) {
+ child.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'start',
+ });
+ return;
+ }
+ }
+ }, []);
+
+ const handleRightClick = useCallback(() => {
+ if (
+ containerRef.current == null ||
+ continuousScrollRef.current.cancelClick
+ ) {
+ return;
+ }
+
+ const { children } = containerRef.current;
+ for (let i = 0; i < children.length; i += 1) {
+ const child = children[i] as HTMLElement;
+ // Add 5px to right edge to account for rounding of offset values
+ if (
+ child.offsetLeft + 5 >
+ containerRef.current.scrollLeft + containerRef.current.offsetWidth
+ ) {
+ child.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'end',
+ });
+ return;
+ }
+ }
+ }, []);
+
+ /**
+ * Recurively called after initial timeout on mousedown. Continuously scroll with acceleration.
+ * Cancelled by mouseup handler cancelling the animationFrame.
+ * @param direction of scroll, left or right
+ * @param startX starting position of scroll
+ * @param deltaX delta from intial startX calculated recursively
+ * @param prevTimestamp called on subsequent delta frames
+ */
+ const handleMouseRepeat = useCallback(
+ (
+ direction: 'left' | 'right',
+ startX: number,
+ deltaX = 0,
+ prevTimestamp?: number
+ ) => {
+ const container = containerRef.current;
+ if (container == null) {
+ return;
+ }
+
+ continuousScrollRef.current.cancelClick = true;
+
+ if (direction === 'left') {
+ // eslint-disable-next-line no-param-reassign
+ container.scrollLeft = startX - deltaX;
+ } else if (direction === 'right') {
+ // eslint-disable-next-line no-param-reassign
+ container.scrollLeft = startX + deltaX;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ continuousScrollRef.current.rAF = requestAnimationFrame(timestamp => {
+ const startTime = prevTimestamp ?? timestamp;
+ const deltaTime = timestamp - startTime;
+ let newDeltaX =
+ START_SPEED * deltaTime + 0.5 * ACCELERATION * deltaTime ** 2;
+ newDeltaX = Math.min(newDeltaX, container.scrollWidth);
+ // scrollLeft enforces a limit but no point letting delta increment beyond scrollWidth
+
+ handleMouseRepeat(direction, startX, newDeltaX, startTime);
+ });
+ },
+ []
+ );
+
+ const endContinuousScroll = useCallback(() => {
+ const { holdTimer, rAF } = continuousScrollRef.current;
+ if (holdTimer != null) {
+ clearTimeout(holdTimer);
+ continuousScrollRef.current.holdTimer = undefined;
+ }
+ if (rAF != null) {
+ cancelAnimationFrame(rAF);
+ continuousScrollRef.current.rAF = undefined;
+ }
+ window.removeEventListener('mouseup', endContinuousScroll);
+ }, []);
+
+ useEffect(
+ () => () => window.removeEventListener('mouseup', endContinuousScroll),
+ [endContinuousScroll]
+ );
+
+ const handleMouseDown = useCallback(
+ (direction: 'left' | 'right') => {
+ if (containerRef.current != null) {
+ continuousScrollRef.current.holdTimer = window.setTimeout(
+ handleMouseRepeat,
+ CLICK_TIMEOUT,
+ direction,
+ containerRef.current.scrollLeft
+ );
+ }
+ continuousScrollRef.current.cancelClick = false;
+ window.addEventListener('mouseup', endContinuousScroll);
+ },
+ [endContinuousScroll, handleMouseRepeat]
+ );
+
+ const handleMouseDownLeft = useCallback(() => {
+ handleMouseDown('left');
+ }, [handleMouseDown]);
+
+ const handleMouseDownRight = useCallback(() => {
+ handleMouseDown('right');
+ }, [handleMouseDown]);
+
+ // React binds to the root as a passive listener for wheel
+ // This prevents the wheel event from being canceled
+ // Bypass React's event system so we can prevent the default behavior
+ // https://github.com/facebook/react/issues/14856
+ useEffect(function handleWheel() {
+ const onWheel = (e: WheelEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const nav = e.currentTarget as HTMLDivElement;
+ const delta =
+ Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
+
+ // Scrolling jumps too far sometimes, so clamp to get a smoother scroll
+ nav.scrollLeft += clamp(delta, -30, 30);
+ };
+
+ containerRef.current?.addEventListener('wheel', onWheel);
+ return () => {
+ containerRef.current?.removeEventListener('wheel', onWheel);
+ };
+ }, []);
+
+ const tabContextActionMap = useMemo(() => {
+ const tabContextActions = new Map();
+ tabs.forEach(tab => {
+ const { key } = tab;
+ const contextActions = [
+ () => makeBaseContextActions(tab, tabs, onClose),
+ () => makeContextActions?.(tab) ?? [],
+ ];
+ tabContextActions.set(key, contextActions);
+ });
+ return tabContextActions;
+ }, [makeContextActions, tabs, onClose]);
+
+ const activeTabRef = useRef(null);
+ const activeTab = tabs.find(tab => tab.key === activeKey);
+ const navTabs = tabs.map((tab, index) => {
+ const { key } = tab;
+ const isActive = tab === activeTab;
+
+ return (
+
+ );
+ });
+
+ useEffect(
+ // Needs to be in a useEffect so the ref is updated
+ function scrollActiveTabIntoView() {
+ if (activeTabRef.current != null) {
+ activeTabRef.current.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ }
+ },
+ [activeKey]
+ );
+
+ return (
+
+ );
+}
+
+export default NavTabList;
diff --git a/packages/components/src/navigation/index.ts b/packages/components/src/navigation/index.ts
index 4b5390afbe..f637e1ade9 100644
--- a/packages/components/src/navigation/index.ts
+++ b/packages/components/src/navigation/index.ts
@@ -2,6 +2,8 @@ export { default as Menu } from './Menu';
export type { MenuProps } from './Menu';
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 Page } from './Page';
export type { PageProps } from './Page';
export { default as Stack } from './Stack';
diff --git a/packages/golden-layout/src/items/RowOrColumn.ts b/packages/golden-layout/src/items/RowOrColumn.ts
index 6335f30ca4..063fb55ff1 100644
--- a/packages/golden-layout/src/items/RowOrColumn.ts
+++ b/packages/golden-layout/src/items/RowOrColumn.ts
@@ -299,8 +299,9 @@ export default class RowOrColumn extends AbstractContentItem {
const dimension = this._isColumn ? 'height' : 'width';
for (let i = 0; i < this.contentItems.length; i++) {
- if (this.contentItems[i].config[dimension] !== undefined) {
- total += this.contentItems[i].config[dimension] ?? 0;
+ const size = this.contentItems[i].config[dimension];
+ if (size != null) {
+ total += size;
} else {
itemsWithoutSetDimension.push(this.contentItems[i]);
}
diff --git a/tests/styleguide.spec.ts-snapshots/navigations-chromium-linux.png b/tests/styleguide.spec.ts-snapshots/navigations-chromium-linux.png
index 8cb49fb269..2b46097733 100644
Binary files a/tests/styleguide.spec.ts-snapshots/navigations-chromium-linux.png and b/tests/styleguide.spec.ts-snapshots/navigations-chromium-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/navigations-firefox-linux.png b/tests/styleguide.spec.ts-snapshots/navigations-firefox-linux.png
index 16a3f03901..7f5bbfd409 100644
Binary files a/tests/styleguide.spec.ts-snapshots/navigations-firefox-linux.png and b/tests/styleguide.spec.ts-snapshots/navigations-firefox-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/navigations-webkit-linux.png b/tests/styleguide.spec.ts-snapshots/navigations-webkit-linux.png
index d88fb3b4be..0a93018856 100644
Binary files a/tests/styleguide.spec.ts-snapshots/navigations-webkit-linux.png and b/tests/styleguide.spec.ts-snapshots/navigations-webkit-linux.png differ