Files
superset2/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
Superset Dev f9da2f93de feat(mobile): consumption-only mobile experience behind MOBILE_CONSUMPTION_MODE
Adds an opt-in, consumption-only mobile experience (feature flag
MOBILE_CONSUMPTION_MODE, default off, @lifecycle: development):

- Dashboards: charts stacked full-width with real plugin dimensions
  (ChartHolder reports full column count on mobile; heights capped to
  the viewport minus chrome), sticky swipeable tab bars with gradient
  overflow affordances, filter bar in a drawer (FilterBar mobileMode),
  compact header (title scrolls away; edit/publish/fave/refresh controls
  hidden; dashboard info moved into the kebab menu)
- Dashboard list: forced card view, full-width cards, search/filters and
  sort in a drawer (single FilterControls instance)
- Home: dashboards-only Recents, compact empty states, desktop-only
  sections hidden
- Navigation: hamburger drawer (dashboards, theme/language, user
  info/logout with row-tap navigation)
- Route guarding: routes declare mobileSupported in routes.tsx;
  everything else renders a MobileUnsupported screen; viewport growth
  unblocks automatically (useIsMobile subscribes to matchMedia only when
  the flag is on, so flag-off deployments have zero render delta)
- Serves a viewport meta tag (flag-gated) so mobile browsers lay out at
  device width instead of the ~980px legacy viewport; exposes
  is_feature_enabled to Jinja via the common context processor
- User docs (using-superset/mobile-experience.mdx) with a Playwright
  screenshot generator following the docs:screenshots pattern
- Docker dev config enables the flag; jest + Playwright coverage
  throughout

Squashed from the iterative mobile-dashboard-support history (preserved
at backup/mobile-pre-rebase-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:12:15 -07:00

336 lines
9.7 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
cloneElement,
memo,
ReactElement,
RefObject,
useCallback,
useRef,
useState,
} from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
} from '@superset-ui/core/components/Tabs';
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
closestCenter,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
width: 100%;
background-color: ${({ theme }) => theme.colorBgContainer};
& .dashboard-component-tabs-content {
height: 100%;
}
& > .hover-menu:hover {
opacity: 1;
}
&.dragdroppable-row .dashboard-component-tabs-content {
height: calc(100% - 47px);
}
/* Ensure tab labels maintain full opacity during drag */
.ant-tabs-tab {
.dragdroppable-tab,
.editable-title,
textarea {
opacity: 1;
color: inherit;
}
}
${({ isDragging }) =>
isDragging &&
`
/* Show the drag indicator during drag, over the tab title textarea too.
The doubled parent outranks the title's own cursor; a single & loses. */
&& .dragdroppable-tab * {
cursor: move;
}
/* Hide ink-bar during drag */
.ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar,
.ant-tabs > .ant-tabs-nav .ant-tabs-ink-bar {
display: none !important;
}
`}
/* Sticky tabs on mobile (consumption mode) */
${({ theme }) =>
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
.ant-tabs-nav {
position: sticky;
top: 0;
z-index: 100;
background-color: ${theme.colorBgContainer};
/* breathing room between the tab bar and the first card; padding
(not margin) so the gap is part of the opaque sticky bar */
padding-bottom: ${theme.sizeUnit * 2}px;
}
/* Scrollability affordance: fade the clipped edge with a
theme-colored gradient. antd toggles the ping classes when tabs
overflow on that side; restyle its shadow elements as gradients,
which read much better than the default shadows on dark themes. */
.ant-tabs-nav-wrap:before,
.ant-tabs-nav-wrap:after {
width: ${theme.sizeUnit * 10}px;
box-shadow: none !important;
pointer-events: none;
}
.ant-tabs-nav-wrap-ping-right:after {
background: linear-gradient(
to right,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
.ant-tabs-nav-wrap-ping-left:before {
background: linear-gradient(
to left,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
/* Swipeable tab bar instead of the overflow dropdown: the "more"
menu is a poor touch target and duplicates half-clipped tabs.
antd's tab nav supports touch-drag scrolling natively and shows
edge shadows (ping classes) when tabs overflow. */
.ant-tabs-nav-operations {
display: none !important;
}
}
`}
`;
export interface TabItem {
key: string;
label: ReactElement;
closeIcon: ReactElement;
children?: ReactElement;
}
export interface TabsComponent {
id: string;
}
export interface TabsRendererProps {
tabItems: TabItem[];
editMode: boolean;
renderHoverMenu?: boolean;
tabsDragSourceRef?: RefObject<HTMLDivElement>;
handleDeleteComponent: () => void;
tabsComponent: TabsComponent;
activeKey: string;
tabIds: string[];
handleClickTab: (index: number) => void;
handleEdit: AntdTabsProps['onEdit'];
tabBarPaddingLeft?: number;
onTabsReorder?: (oldIndex: number, newIndex: number) => void;
isEditingTabTitle?: boolean;
onTabTitleEditingChange?: (isEditing: boolean) => void;
}
interface DraggableTabNodeProps extends React.HTMLAttributes<HTMLDivElement> {
'data-node-key': string;
disabled?: boolean;
}
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({
disabled = false,
...props
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props['data-node-key'],
disabled,
});
const style: React.CSSProperties = {
...props.style,
position: 'relative',
transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined,
transition: isDragging ? 'none' : transition,
cursor: disabled ? 'default' : 'move',
zIndex: isDragging ? 1000 : 'auto',
opacity: 1,
};
return cloneElement(props.children as React.ReactElement, {
ref: setNodeRef,
style,
...attributes,
...(disabled ? {} : listeners),
});
};
/**
* TabsRenderer component handles the rendering of dashboard tabs
* Extracted from the main Tabs component for better separation of concerns
*/
const TabsRenderer = memo<TabsRendererProps>(
({
tabItems,
editMode,
renderHoverMenu = true,
tabsDragSourceRef,
handleDeleteComponent,
tabsComponent,
activeKey,
tabIds,
handleClickTab,
handleEdit,
tabBarPaddingLeft = 0,
onTabsReorder,
isEditingTabTitle = false,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
// Use ref to always have access to the current tabIds in callbacks
const tabIdsRef = useRef(tabIds);
tabIdsRef.current = tabIds;
const sensor = useSensor(PointerSensor, {
activationConstraint: { distance: 10 },
});
const onDragStart = useCallback((event: any) => {
setActiveId(event.active.id);
}, []);
const onDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
const currentTabIds = tabIdsRef.current;
// Only reorder when we have a valid drop target and both IDs are found
if (active.id !== over?.id && onTabsReorder) {
const activeIndex = currentTabIds.findIndex(id => id === active.id);
const overIndex = currentTabIds.findIndex(id => id === over?.id);
if (activeIndex !== -1 && overIndex !== -1) {
onTabsReorder(activeIndex, overIndex);
}
}
setActiveId(null);
},
[onTabsReorder],
);
const onDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const isDragging = activeId !== null;
return (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</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}
>
<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>
),
})}
/>
</StyledTabsContainer>
);
},
);
TabsRenderer.displayName = 'TabsRenderer';
export default TabsRenderer;