diff --git a/packages/code-studio/src/main/AppMainContainer.scss b/packages/code-studio/src/main/AppMainContainer.scss index f8b4f2f2dd..450484b2a2 100644 --- a/packages/code-studio/src/main/AppMainContainer.scss +++ b/packages/code-studio/src/main/AppMainContainer.scss @@ -1,253 +1,14 @@ @import '@deephaven/components/scss/custom.scss'; $tab-height: 32px; -$tab-drag-border-width: 1px; $tab-font-size: 1rem; -$tab-link-max-width: 200px; - -$tab-link-side-padding: 24px; -$tab-link-underline-spacing: 6px; - -$tab-close-right: 0.25rem; -$tab-close-bottom: 6px; -$tab-close-padding-x: 1px; -$tab-close-padding-y: 2px; -$tab-close-color: $gray-500; -$tab-close-hover-color: $gray-200; $tab-button-side-padding: 9px; -$tab-button-separator-height: 16px; - -$tab-link-color: $gray-400; - -$tab-link-hover-color: $gray-300; -$tab-link-hover-underline-color: $gray-400; - -$tab-link-active-color: $gray-200; -$tab-link-active-underline-color: $primary; - -$tab-link-active-hover-color: $gray-200; -$tab-link-active-hover-underline-color: $primary; $tab-link-disabled-color: $gray-600; -$tab-button-hover-color: $gray-200; -$tab-button-separator-color: $gray-600; - -$tab-dragging-bg-color: $primary-dark; -$tab-dragging-ant-color: $gray-300; - -$tab-control-btn-width: 25px; -$tab-control-btn-offset: -8px; -$tab-control-gradient-width: 12px; - $nav-space: 4px; // give a gap around some buttons for focus area that are in nav bar -@mixin underlined-nav-link($pseudo-element, $underline-color) { - &::#{$pseudo-element} { - content: ''; - position: absolute; - height: 1px; - left: $tab-link-side-padding; - right: $tab-link-side-padding; - bottom: $tab-link-underline-spacing; - background: $underline-color; - transition: all $transition-mid ease-out; - @content; - } -} - -.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($tab-dragging-ant-color, $background); - } - - .btn-nav-tab { - color: $tab-link-color; - border: $tab-drag-border-width solid transparent; - line-height: $tab-height - $tab-drag-border-width * 2; // subtract top and bottom borders, and focus border - width: auto; - max-width: $tab-link-max-width; - 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; - line-height: $tab-font-size; - right: $tab-close-right; - bottom: $tab-close-bottom; - padding: $tab-close-padding-y $tab-close-padding-x; - color: $tab-close-color; - opacity: 0; - transition: opacity $transition ease-out; - - &:hover { - color: $tab-button-hover-color; - } - - &:focus { - opacity: 1; - color: $tab-button-hover-color; - } - } - - //hover line is drawn as a before element - @include underlined-nav-link(before, transparent) { - transform: translateY($tab-link-underline-spacing); - } - - //active is drawn animated overtop as after element - @include underlined-nav-link(after, $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 { - color: $tab-link-hover-color; - 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 { - background: $tab-link-active-underline-color; - transform: scaleX(1); - } - &::before { - transform: translateY(0); - } - } - - &.dragging { - color: $tab-link-active-color; - background-color: $tab-dragging-bg-color; - - .btn-nav-tab-close { - opacity: 0; - } - - &::before { - box-shadow: none; - } - - &::after { - background: $tab-dragging-bg-color; - } - } - } - } - - .tab-controls-backward { - flex-shrink: 0; - background-image: linear-gradient( - 270deg, - hsla(var(--dh-color-bg-hsl), 0) 0%, - $background $tab-control-gradient-width - ); - background-clip: content-box; - height: auto; - width: $tab-control-btn-width; - padding: 0; - margin-right: $tab-control-btn-offset; - border-radius: $border-radius; - border: 0; - min-width: unset; - z-index: 2; - } - - .tab-controls-forward { - background-image: linear-gradient( - 90deg, - hsla(var(--dh-color-bg-hsl), 0) 0%, - $background $tab-control-gradient-width - ); - background-clip: content-box; - height: 100%; - border-radius: $border-radius; - width: $tab-control-btn-width; - padding: 0; - margin-left: $tab-control-btn-offset; - } - - .tab-controls { - margin-right: auto; - white-space: nowrap; - z-index: 2; - - .btn { - min-width: unset; - height: $tab-height; - } - - .btn-new-tab { - min-width: auto; - padding: 0 $tab-button-side-padding; - position: relative; - white-space: nowrap; - height: $tab-height - $nav-space; - line-height: $tab-height - $nav-space - $input-border-width * 2; - margin: $nav-space * 0.5 0 $nav-space * 0.5 $tab-button-side-padding; - - &::before { - content: ''; - position: absolute; - left: -$tab-button-side-padding; - width: 1px; - top: ($tab-height - $tab-button-separator-height) * 0.5 - $nav-space * - 0.5 - $input-border-width; - height: $tab-button-separator-height; - background: $tab-button-separator-color; - } - &:hover, - &:focus { - color: $tab-button-hover-color; - text-decoration: none; - } - } - } -} - .grid-cursor-copy { cursor: url('../assets/svg/cursor-copy.svg') 8 8, diff --git a/packages/code-studio/src/styleguide/Navigations.scss b/packages/code-studio/src/styleguide/Navigations.scss deleted file mode 100644 index cbfdab0a0d..0000000000 --- a/packages/code-studio/src/styleguide/Navigations.scss +++ /dev/null @@ -1,8 +0,0 @@ -@import '@deephaven/components/scss/custom.scss'; - -.navigations { - position: relative; - height: 400; - width: 325; - border: 1px solid $background; -} diff --git a/packages/code-studio/src/styleguide/Navigations.tsx b/packages/code-studio/src/styleguide/Navigations.tsx index 0232226b9c..7dce987ec3 100644 --- a/packages/code-studio/src/styleguide/Navigations.tsx +++ b/packages/code-studio/src/styleguide/Navigations.tsx @@ -2,9 +2,90 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { vsFile, dhTruck, vsListUnordered } from '@deephaven/icons'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { Menu, Page, Stack } from '@deephaven/components'; +import { + Menu, + NavTabList, + Page, + Stack, + type NavTabItem, +} from '@deephaven/components'; import { pseudoRandomWithSeed, sampleSectionIdAndClasses } from './utils'; +function NavTabListExample({ + count = 5, + activeKey: activeKeyProp = '', +}: { + count?: number; + activeKey?: string; +}) { + const [activeKey, setActiveKey] = useState(activeKeyProp); + const [tabs, setTabs] = useState(() => { + const tabItems: NavTabItem[] = []; + for (let i = 0; i < count; i += 1) { + tabItems.push({ key: `${i}`, title: `Tab ${i}`, isClosable: i > 0 }); + } + return tabItems; + }); + + const handleReorder = useCallback((from: number, to: number) => { + setTabs(t => { + const newTabs = [...t]; + const [removed] = newTabs.splice(from, 1); + newTabs.splice(to, 0, removed); + return newTabs; + }); + }, []); + + const handleSelect = useCallback((key: string) => { + setActiveKey(key); + }, []); + + const handleClose = useCallback((key: string) => { + setTabs(t => t.filter(tab => tab.key !== key)); + }, []); + + const makeContextActions = useCallback( + (tab: NavTabItem) => [ + { + title: 'Select Tab to the Left', + group: 10, + order: 10, + disabled: tabs[0].key === tab.key, + action: () => { + const index = tabs.findIndex(t => t.key === tab.key); + if (index > 0) { + setActiveKey(tabs[index - 1].key); + } + }, + }, + { + title: 'Select Tab to the Right', + group: 30, + order: 10, + disabled: tabs[tabs.length - 1].key === tab.key, + action: () => { + const index = tabs.findIndex(t => t.key === tab.key); + if (index < tabs.length - 1) { + setActiveKey(tabs[index + 1].key); + } + }, + }, + ], + [tabs] + ); + + return ( + + ); +} + enum MENU_ITEM_TYPE { SUBMENU = 'SUBMENU', PAGE = 'PAGE', @@ -208,6 +289,12 @@ function Navigations(): JSX.Element { return (

Navigations

+
+ +
+
+ +
{stack}
diff --git a/packages/code-studio/src/styleguide/StyleGuide.test.tsx b/packages/code-studio/src/styleguide/StyleGuide.test.tsx index d6d2d324d4..c346adc6d9 100644 --- a/packages/code-studio/src/styleguide/StyleGuide.test.tsx +++ b/packages/code-studio/src/styleguide/StyleGuide.test.tsx @@ -5,6 +5,9 @@ import { dh } from '@deephaven/jsapi-shim'; import { ApiContext } from '@deephaven/jsapi-bootstrap'; import StyleGuide from './StyleGuide'; +window.HTMLElement.prototype.scroll = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + describe(' mounts', () => { test('h1 text of StyleGuide renders', () => { // Provide a non-null array to ThemeProvider to tell it to initialize diff --git a/packages/components/scss/BaseStyleSheet.scss b/packages/components/scss/BaseStyleSheet.scss index e1ae369719..a476d770d1 100644 --- a/packages/components/scss/BaseStyleSheet.scss +++ b/packages/components/scss/BaseStyleSheet.scss @@ -139,7 +139,7 @@ button:focus { } span.btn-disabled-wrapper { - display: inline-block; + display: contents; .btn.disabled, .btn:disabled { pointer-events: none; diff --git a/packages/components/src/context-actions/ContextActions.tsx b/packages/components/src/context-actions/ContextActions.tsx index b192e1ec4b..ab45275490 100644 --- a/packages/components/src/context-actions/ContextActions.tsx +++ b/packages/components/src/context-actions/ContextActions.tsx @@ -14,7 +14,7 @@ import './ContextActions.scss'; const log = Log.module('ContextActions'); interface ContextActionsProps { - actions: ResolvableContextAction | ResolvableContextAction[]; + actions?: ResolvableContextAction | ResolvableContextAction[]; ignoreClassNames?: string[]; 'data-testid'?: string; } diff --git a/packages/components/src/navigation/NavTab.tsx b/packages/components/src/navigation/NavTab.tsx new file mode 100644 index 0000000000..79206d3a76 --- /dev/null +++ b/packages/components/src/navigation/NavTab.tsx @@ -0,0 +1,96 @@ +import React, { memo } from 'react'; +import classNames from 'classnames'; +import { Draggable } from 'react-beautiful-dnd'; +import { vsClose } from '@deephaven/icons'; +import type { NavTabItem } from './NavTabList'; +import Button from '../Button'; +import ContextActions from '../context-actions/ContextActions'; +import { ResolvableContextAction } from '../context-actions'; + +interface NavTabProps { + tab: NavTabItem; + onSelect: (key: string) => void; + onClose?: (key: string) => void; + isActive: boolean; + activeRef: React.RefObject; + index: number; + isDraggable: boolean; + contextActions?: ResolvableContextAction | ResolvableContextAction[]; +} + +const NavTab = memo( + ({ + tab, + onClose, + onSelect, + isActive, + activeRef, + index, + isDraggable, + contextActions, + }: NavTabProps) => { + const { key, isClosable = onClose != null, title } = tab; + + return ( + + {(provided, snapshot) => ( +
+
{ + (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