This is an automated email from the ASF dual-hosted git repository.
sfirke pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f69023a9c6c fix(dashboard): freeze nested tabs to the top when
scrolling down (#43940)
f69023a9c6c is described below
commit f69023a9c6c1b336215a942b563781216ec73008
Author: Sam Firke <[email protected]>
AuthorDate: Wed Sep 9 15:56:23 2026 -0400
fix(dashboard): freeze nested tabs to the top when scrolling down (#43940)
---
.../DashboardBuilder/DashboardBuilder.test.tsx | 98 ++++++++++-
.../DashboardBuilder/DashboardBuilder.tsx | 19 ++-
.../{index.ts => StickyTabsOffsetContext.ts} | 20 ++-
.../TabsRenderer/TabsRenderer.test.tsx | 171 +++++++++++++++++++-
.../gridComponents/TabsRenderer/TabsRenderer.tsx | 179 +++++++++++++++------
.../gridComponents/TabsRenderer/index.ts | 1 +
6 files changed, 434 insertions(+), 54 deletions(-)
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
index d2a196845e7..0153d1ab34a 100644
---
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
@@ -115,8 +115,21 @@
jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => {
MockFilterBar.displayName = 'MockFilterBar';
return MockFilterBar;
});
+// Exposes the sticky offset the builder hands to tab bars in the grid.
jest.mock('src/dashboard/containers/DashboardGrid', () => {
- const MockDashboardGrid = () => <div data-test="mock-dashboard-grid" />;
+ const { useContext } = jest.requireActual('react');
+ const { StickyTabsOffsetContext } = jest.requireActual(
+
'src/dashboard/components/gridComponents/TabsRenderer/StickyTabsOffsetContext',
+ );
+ const MockDashboardGrid = () => {
+ const stickyTabsOffset = useContext(StickyTabsOffsetContext);
+ return (
+ <div
+ data-test="mock-dashboard-grid"
+ data-sticky-tabs-offset={stickyTabsOffset ?? 'none'}
+ />
+ );
+ };
MockDashboardGrid.displayName = 'MockDashboardGrid';
return MockDashboardGrid;
});
@@ -179,6 +192,89 @@ describe('DashboardBuilder', () => {
expect(stickyContainer).toHaveClass('dashboard');
});
+ // jsdom lays nothing out; report a height for the sticky header so the
+ // offset handed to the grid is distinguishable from "no offset".
+ function mockHeaderHeight(height: number) {
+ return jest
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(function measure(this: HTMLElement) {
+ const size =
+ this.dataset.test === 'dashboard-header-wrapper' ? height : 0;
+ return {
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: size,
+ width: 0,
+ height: size,
+ toJSON: () => ({}),
+ } as DOMRect;
+ });
+ }
+
+ test('hands the sticky header height to tab bars in the grid while viewing',
async () => {
+ const rectSpy = mockHeaderHeight(120);
+ try {
+ const { findByTestId } = setup();
+ expect(await findByTestId('mock-dashboard-grid')).toHaveAttribute(
+ 'data-sticky-tabs-offset',
+ '120',
+ );
+ } finally {
+ rectSpy.mockRestore();
+ }
+ });
+
+ test('leaves tab bars in the grid unpinned in report mode (?standalone=3)',
async () => {
+ // Report screenshots of large dashboards are captured tile by tile while
+ // scrolling the page; a pinned bar would repeat in every tile.
+ const originalHref = window.location.href;
+ window.history.replaceState({}, '', '/?standalone=3');
+ const rectSpy = mockHeaderHeight(120);
+ try {
+ const { findByTestId } = setup();
+ expect(await findByTestId('mock-dashboard-grid')).toHaveAttribute(
+ 'data-sticky-tabs-offset',
+ 'none',
+ );
+ } finally {
+ rectSpy.mockRestore();
+ window.history.replaceState({}, '', originalHref);
+ }
+ });
+
+ test('leaves tab bars in the grid unpinned while a chart is maximized',
async () => {
+ const rectSpy = mockHeaderHeight(120);
+ try {
+ const { findByTestId } = setup({
+ dashboardState: { ...mockState.dashboardState, fullSizeChartId: 123 },
+ });
+ expect(await findByTestId('mock-dashboard-grid')).toHaveAttribute(
+ 'data-sticky-tabs-offset',
+ 'none',
+ );
+ } finally {
+ rectSpy.mockRestore();
+ }
+ });
+
+ test('leaves tab bars in the grid unpinned in the mobile viewport', async ()
=> {
+ (useIsMobile as jest.Mock).mockReturnValue(true);
+ const rectSpy = mockHeaderHeight(120);
+ try {
+ const { findByTestId } = setup();
+ expect(await findByTestId('mock-dashboard-grid')).toHaveAttribute(
+ 'data-sticky-tabs-offset',
+ 'none',
+ );
+ } finally {
+ rectSpy.mockRestore();
+ (useIsMobile as jest.Mock).mockReturnValue(false);
+ }
+ });
+
test('should add the "dashboard--editing" class if editMode=true', () => {
const { getByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index 62e807fb35a..98189ddb497 100644
---
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -87,6 +87,7 @@ import {
} from 'src/dashboard/constants';
import { selectCanRestoreDashboard } from
'src/features/versionHistory/canRestoreDashboard';
import { selectIsDashboardVersionPreviewActive } from
'src/features/versionHistory/reducer';
+import { StickyTabsOffsetContext } from
'src/dashboard/components/gridComponents/TabsRenderer';
import { getRootLevelTabsComponent, shouldFocusTabs } from './utils';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
@@ -490,8 +491,9 @@ const DashboardBuilder = () => {
// always get the desktop layout -- matching the pre-existing behavior the
// docs already promise for embedded dashboards.
const standaloneMode = getUrlParam(URL_PARAMS.standalone);
+ const isMobileViewport = useIsMobile();
const isNotMobile =
- !useIsMobile() || standaloneMode !== DashboardStandaloneMode.None;
+ !isMobileViewport || standaloneMode !== DashboardStandaloneMode.None;
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
// Reset the drawer's open state when leaving mobile mode so it doesn't
@@ -774,6 +776,17 @@ const DashboardBuilder = () => {
? theme.sizeUnit * 4
: theme.sizeUnit * 8;
+ // Tab bars nested in the grid pin just below the sticky header while the
+ // page scrolls. Not in the mobile viewport, where the header scrolls away
+ // and the mobile styling pins tab bars on its own; not in report mode,
+ // whose tiled screenshots scroll the page and would capture a pinned bar
+ // in every tile; and not while a chart is maximized, which sits inside its
+ // own stacking context and must not be covered by a pinned bar.
+ // (TabsRenderer itself opts out while editing, since drop targets rely on
+ // document flow.)
+ const stickyTabsOffset =
+ isMobileViewport || isReport || fullSizeChartId ? undefined : barTopOffset;
+
const renderChild = useCallback(
(adjustedWidth: number) => {
const filterBarWidth = dashboardFiltersOpen
@@ -976,7 +989,9 @@ const DashboardBuilder = () => {
/>
</div>
) : (
- <DashboardContainer topLevelTabs={topLevelTabs} />
+ <StickyTabsOffsetContext.Provider value={stickyTabsOffset}>
+ <DashboardContainer topLevelTabs={topLevelTabs} />
+ </StickyTabsOffsetContext.Provider>
)
) : (
<Loading />
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/StickyTabsOffsetContext.ts
similarity index 50%
copy from
superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
copy to
superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/StickyTabsOffsetContext.ts
index 320e8ebb022..60262f10f9b 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
+++
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/StickyTabsOffsetContext.ts
@@ -16,5 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
-export { default } from './TabsRenderer';
-export type { TabsRendererProps, TabItem, TabsComponent } from
'./TabsRenderer';
+import { createContext } from 'react';
+
+/**
+ * Distance, in pixels, from the top of the viewport at which a tab bar
+ * rendered inside the dashboard grid should stick while the page scrolls.
+ *
+ * The dashboard header (title plus top-level tabs) is itself sticky, so the
+ * first level of nested tabs pins just below it. Each level of nested tabs
+ * then adds its own tab bar height for the tabs it contains, so deeper tab
+ * bars stack beneath the ones above them instead of overlapping.
+ *
+ * `undefined` disables sticky tab bars, which is the case while editing
+ * (drag-and-drop targets are laid out in document flow) and in the mobile
+ * consumption experience, which pins tab bars through its own styling.
+ */
+export const StickyTabsOffsetContext = createContext<number | undefined>(
+ undefined,
+);
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx
index 3f914f5546f..c74dc34ee47 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx
+++
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx
@@ -16,8 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { fireEvent, render, screen } from 'spec/helpers/testing-library';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ sleep,
+} from 'spec/helpers/testing-library';
import TabsRenderer, { TabItem, TabsRendererProps } from './TabsRenderer';
+import { StickyTabsOffsetContext } from './StickyTabsOffsetContext';
const mockTabItems: TabItem[] = [
{
@@ -240,7 +247,7 @@ describe('TabsRenderer', () => {
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); //
Not active
});
- test('drags from the tab title and shows the drag indicator only then', ()
=> {
+ test('drags from the tab title and shows the drag indicator only then',
async () => {
render(<TabsRenderer {...draggableTabProps} />);
const container = screen.getByTestId('dashboard-component-tabs');
const title = container.querySelector('textarea') as HTMLTextAreaElement;
@@ -263,5 +270,165 @@ describe('TabsRenderer', () => {
expect(container).toHaveStyleRule('cursor', 'move', {
target: '.dragdroppable-tab *',
});
+
+ // Release the pointer so the drag does not outlive this test. dnd-kit
+ // keeps swallowing clicks on the shared document for 50ms after a drag
+ // ends, which would eat the tab click of whichever test runs next.
+ fireEvent.pointerUp(document, { button: 0, isPrimary: true, clientX: 50 });
+ await sleep(60);
+ });
+
+ // jsdom's cascade ignores specificity, so assert on the emotion rule rather
+ // than the computed style, which antd's own `position: relative` would win
+ const TAB_BAR = { target: /> ?\.ant-tabs ?> ?\.ant-tabs-nav$/ };
+
+ test('pins the tab bar below the offset supplied by the dashboard', () => {
+ render(
+ <StickyTabsOffsetContext.Provider value={64}>
+ <TabsRenderer {...mockProps} />
+ </StickyTabsOffsetContext.Provider>,
+ );
+ const container = screen.getByTestId('dashboard-component-tabs');
+
+ expect(container).toHaveStyleRule('position', 'sticky', TAB_BAR);
+ expect(container).toHaveStyleRule('top', '64px', TAB_BAR);
+ });
+
+ test('leaves the tab bar in document flow without a dashboard offset', () =>
{
+ render(<TabsRenderer {...mockProps} />);
+ const container = screen.getByTestId('dashboard-component-tabs');
+
+ expect(container).not.toHaveStyleRule('position', 'sticky', TAB_BAR);
+ });
+
+ test('leaves the tab bar in document flow in edit mode', () => {
+ render(
+ <StickyTabsOffsetContext.Provider value={64}>
+ <TabsRenderer {...mockProps} editMode />
+ </StickyTabsOffsetContext.Provider>,
+ );
+ const container = screen.getByTestId('dashboard-component-tabs');
+
+ expect(container).not.toHaveStyleRule('position', 'sticky', TAB_BAR);
+ });
+
+ // Reports the offset this tab set hands to tab sets nested inside it.
+ const nestedTabItems: TabItem[] = [
+ {
+ ...mockTabItems[0],
+ children: (
+ <StickyTabsOffsetContext.Consumer>
+ {offset => <div data-test="nested-offset">{offset}</div>}
+ </StickyTabsOffsetContext.Consumer>
+ ),
+ },
+ mockTabItems[1],
+ ];
+
+ test('stacks nested tab bars beneath its own tab bar', () => {
+ // jsdom lays nothing out, so give the tab bar a height to add up
+ const heightSpy = jest
+ .spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
+ .mockReturnValue(40);
+
+ try {
+ render(
+ <StickyTabsOffsetContext.Provider value={64}>
+ <TabsRenderer {...mockProps} tabItems={nestedTabItems} />
+ </StickyTabsOffsetContext.Provider>,
+ );
+
+ expect(screen.getByTestId('nested-offset')).toHaveTextContent('104');
+ } finally {
+ heightSpy.mockRestore();
+ }
+ });
+
+ test('restacks nested tab bars when its own tab bar reflows', () => {
+ // The shared jsdom shim never fires its callback, so stand in an observer
+ // that hands the callback back to the test. The component re-measures the
+ // element rather than reading the entries, so the height comes from the
+ // spy below; the callback only needs to run.
+ const observerCallbacks: ResizeObserverCallback[] = [];
+ const RealResizeObserver = window.ResizeObserver;
+ window.ResizeObserver = class {
+ constructor(callback: ResizeObserverCallback) {
+ observerCallbacks.push(callback);
+ }
+
+ observe() {}
+
+ unobserve() {}
+
+ disconnect() {}
+ } as unknown as typeof ResizeObserver;
+ const heightSpy = jest
+ .spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
+ .mockReturnValue(40);
+
+ try {
+ render(
+ <StickyTabsOffsetContext.Provider value={64}>
+ <TabsRenderer {...mockProps} tabItems={nestedTabItems} />
+ </StickyTabsOffsetContext.Provider>,
+ );
+ expect(screen.getByTestId('nested-offset')).toHaveTextContent('104');
+
+ // The bar grows -- labels wrap on a narrow viewport, a webfont lands --
+ // and the tab set below it has to move down by the same amount.
+ heightSpy.mockReturnValue(80);
+ act(() => {
+ observerCallbacks.forEach(callback =>
+ callback([], {} as ResizeObserver),
+ );
+ });
+
+ expect(screen.getByTestId('nested-offset')).toHaveTextContent('144');
+ } finally {
+ heightSpy.mockRestore();
+ window.ResizeObserver = RealResizeObserver;
+ }
+ });
+
+ // Switching tabs while the bar is pinned: the page scrolls so the tab set
+ // starts where its bar is pinned, mirroring the top-level tabs' jump to top
+ function renderPinnedTabSet(containerTop: number, offset?: number) {
+ const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation();
+ const rectSpy = jest
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockReturnValue({ top: containerTop } as DOMRect);
+ const scrollYStub = jest.replaceProperty(window, 'scrollY', 500);
+ render(
+ <StickyTabsOffsetContext.Provider value={offset}>
+ <TabsRenderer {...mockProps} />
+ </StickyTabsOffsetContext.Provider>,
+ );
+ fireEvent.click(screen.getByText('Tab 2').closest('[role="tab"]')!);
+ rectSpy.mockRestore();
+ scrollYStub.restore();
+ return scrollTo;
+ }
+
+ test('scrolls a pinned tab set back to its top when switching tabs', () => {
+ // the tab set's top is 200px above the viewport, so the bar is pinned
+ const scrollTo = renderPinnedTabSet(-200, 64);
+
+ expect(scrollTo).toHaveBeenCalledWith(window.scrollX, 500 - 200 - 64);
+ scrollTo.mockRestore();
+ });
+
+ test('leaves the page alone when the tab bar is not pinned', () => {
+ // the tab set starts below where its bar would pin, so it is in flow
+ const scrollTo = renderPinnedTabSet(300, 64);
+
+ expect(scrollTo).not.toHaveBeenCalled();
+ scrollTo.mockRestore();
+ });
+
+ test('leaves the page alone without a dashboard offset', () => {
+ const scrollTo = renderPinnedTabSet(-200);
+
+ expect(scrollTo).not.toHaveBeenCalled();
+ scrollTo.mockRestore();
});
});
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
index d5a4045f9a8..1d2797fb192 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
+++
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
@@ -22,6 +22,8 @@ import {
ReactElement,
RefObject,
useCallback,
+ useContext,
+ useEffect,
useRef,
useState,
} from 'react';
@@ -46,11 +48,33 @@ import {
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
+import { StickyTabsOffsetContext } from './StickyTabsOffsetContext';
-const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
+// @z-index-above-dashboard-charts: above chart content, below the sticky
+// dashboard header (99) and the filter bar (11)
+const STICKY_TAB_BAR_Z_INDEX = 10;
+
+const StyledTabsContainer = styled.div<{
+ isDragging?: boolean;
+ stickyTop?: number;
+}>`
width: 100%;
background-color: ${({ theme }) => theme.colorBgContainer};
+ /* Pin this component's own tab bar (direct child only, so nested tab
+ sets keep their own offsets) below the sticky dashboard header while
+ its content scrolls. */
+ ${({ theme, stickyTop }) =>
+ stickyTop !== undefined &&
+ css`
+ & > .ant-tabs > .ant-tabs-nav {
+ position: sticky;
+ top: ${stickyTop}px;
+ z-index: ${STICKY_TAB_BAR_Z_INDEX};
+ background-color: ${theme.colorBgContainer};
+ }
+ `}
+
& .dashboard-component-tabs-content {
height: 100%;
}
@@ -231,6 +255,59 @@ const TabsRenderer = memo<TabsRendererProps>(
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
+ // Sticky tab bars only apply in view mode: while editing, drag-and-drop
+ // targets and hover menus are positioned against the tab bar's place in
+ // document flow.
+ const parentStickyOffset = useContext(StickyTabsOffsetContext);
+ const stickyTop = editMode ? undefined : parentStickyOffset;
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [tabBarHeight, setTabBarHeight] = useState(0);
+
+ // Keyed on whether the bar is pinned rather than on the offset itself:
+ // the header re-measuring on every resize would otherwise tear down and
+ // recreate the observer in every tab set on the page.
+ const isPinned = stickyTop !== undefined;
+ useEffect(() => {
+ // Direct-child selector, so nested tab sets' bars are never matched.
+ const tabBar = isPinned
+ ? containerRef.current?.querySelector<HTMLElement>(
+ ':scope > .ant-tabs > .ant-tabs-nav',
+ )
+ : null;
+ if (!tabBar) {
+ return undefined;
+ }
+ const measure = () => setTabBarHeight(tabBar.offsetHeight);
+ measure();
+ // Matches the sticky header's own guard in DashboardBuilder, for
+ // environments without ResizeObserver: the bar still pins, it just
+ // keeps the height measured at mount.
+ if (!global.hasOwnProperty('ResizeObserver')) {
+ return undefined;
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(tabBar);
+ return () => observer.disconnect();
+ }, [isPinned]);
+
+ // Tabs nested inside this one stack their bar beneath ours.
+ const childStickyOffset =
+ stickyTop === undefined ? undefined : stickyTop + tabBarHeight;
+
+ // Counterpart of the top-level tabs, which scroll the page to the top on
+ // every switch (DashboardBuilder.handleChangeTab). With this bar pinned,
+ // a switch would otherwise land the reader partway down the new tab's
+ // content; bring the tab set back to where its bar is pinned instead.
+ const scrollPinnedTabSetToTop = () => {
+ if (stickyTop === undefined || !containerRef.current) {
+ return;
+ }
+ const { top } = containerRef.current.getBoundingClientRect();
+ if (top < stickyTop) {
+ window.scrollTo(window.scrollX, window.scrollY + top - stickyTop);
+ }
+ };
+
// Use ref to always have access to the current tabIds in callbacks
const tabIdsRef = useRef(tabIds);
tabIdsRef.current = tabIds;
@@ -267,9 +344,11 @@ const TabsRenderer = memo<TabsRendererProps>(
return (
<StyledTabsContainer
+ ref={containerRef}
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
+ stickyTop={stickyTop}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
@@ -278,53 +357,59 @@ const TabsRenderer = memo<TabsRendererProps>(
</HoverMenu>
)}
- <LineEditableTabs
- id={tabsComponent.id}
- activeKey={activeKey}
- onChange={key => {
- if (typeof key === 'string') {
- const tabIndex = tabIds.indexOf(key);
- if (tabIndex !== -1) handleClickTab(tabIndex);
- }
- }}
- onEdit={handleEdit}
- data-test="nav-list"
- type={editMode ? 'editable-card' : 'card'}
- items={tabItems}
- tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
- fullHeight
- {...(editMode && {
- renderTabBar: (tabBarProps, DefaultTabBar) => (
- <DndContext
- key={tabIds.join('-')}
- sensors={[sensor]}
- onDragStart={onDragStart}
- onDragEnd={onDragEnd}
- onDragCancel={onDragCancel}
- collisionDetection={closestCenter}
- >
- <SortableContext
- items={tabIds}
- strategy={horizontalListSortingStrategy}
+ <StickyTabsOffsetContext.Provider value={childStickyOffset}>
+ <LineEditableTabs
+ id={tabsComponent.id}
+ activeKey={activeKey}
+ onChange={key => {
+ if (typeof key === 'string') {
+ const tabIndex = tabIds.indexOf(key);
+ if (tabIndex !== -1) {
+ handleClickTab(tabIndex);
+ scrollPinnedTabSetToTop();
+ }
+ }
+ }}
+ onEdit={handleEdit}
+ data-test="nav-list"
+ type={editMode ? 'editable-card' : 'card'}
+ items={tabItems}
+ tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
+ fullHeight
+ {...(editMode && {
+ renderTabBar: (tabBarProps, DefaultTabBar) => (
+ <DndContext
+ key={tabIds.join('-')}
+ sensors={[sensor]}
+ onDragStart={onDragStart}
+ onDragEnd={onDragEnd}
+ onDragCancel={onDragCancel}
+ collisionDetection={closestCenter}
>
- <DefaultTabBar {...tabBarProps}>
- {(node: React.ReactElement) => (
- <DraggableTabNode
- {...(node as React.ReactElement<DraggableTabNodeProps>)
- .props}
- key={node.key}
- data-node-key={node.key as string}
- disabled={isEditingTabTitle}
- >
- {node}
- </DraggableTabNode>
- )}
- </DefaultTabBar>
- </SortableContext>
- </DndContext>
- ),
- })}
- />
+ <SortableContext
+ items={tabIds}
+ strategy={horizontalListSortingStrategy}
+ >
+ <DefaultTabBar {...tabBarProps}>
+ {(node: React.ReactElement) => (
+ <DraggableTabNode
+ {...(
+ node as React.ReactElement<DraggableTabNodeProps>
+ ).props}
+ key={node.key}
+ data-node-key={node.key as string}
+ disabled={isEditingTabTitle}
+ >
+ {node}
+ </DraggableTabNode>
+ )}
+ </DefaultTabBar>
+ </SortableContext>
+ </DndContext>
+ ),
+ })}
+ />
+ </StickyTabsOffsetContext.Provider>
</StyledTabsContainer>
);
},
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
index 320e8ebb022..9c4e0257d3c 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
+++
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts
@@ -18,3 +18,4 @@
*/
export { default } from './TabsRenderer';
export type { TabsRendererProps, TabItem, TabsComponent } from
'./TabsRenderer';
+export { StickyTabsOffsetContext } from './StickyTabsOffsetContext';