diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 08ce0532614d..9a8757a50011 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8321,6 +8321,7 @@ const CONST = {
HTML_RENDERER: {
IMAGE: 'HTMLRenderer-Image',
PRE: 'HTMLRenderer-Pre',
+ VICTORY_CHART_EXPAND_BUTTON: 'HTMLRenderer-VictoryChartExpandButton',
},
RECEIPT: {
IMAGE: 'Receipt-Image',
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx
index 52ead75f77f0..9ba8763c6afa 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx
@@ -10,6 +10,7 @@ import type {VictoryChartRendererProps} from './types';
import VictoryChartContainer from './components/VictoryChartContainer';
import VictoryChartContent from './components/VictoryChartContent';
+import VictoryChartExpandable from './components/VictoryChartExpandable';
import {VictoryChartProvider} from './context/VictoryChartContext';
import processVictoryChartTree from './parsers/processVictoryChartTree';
import resolveVictoryChartType from './utils/resolveVictoryChartType';
@@ -39,9 +40,11 @@ function BaseVictoryChartRenderer({tnode}: VictoryChartRendererProps) {
processedResult={processedResult}
type={type}
>
-
-
-
+
+
+
+
+
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx
new file mode 100644
index 000000000000..9da4ee5de273
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandButton.tsx
@@ -0,0 +1,53 @@
+import Button from '@components/ButtonComposed';
+import Tooltip from '@components/Tooltip';
+
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+import {View} from 'react-native';
+
+type VictoryChartExpandButtonProps = {
+ /** Called when the user presses the expand button */
+ onPress: () => void;
+
+ /** Whether the button should be visible. It stays mounted (hidden via opacity) so hover in/out doesn't reflow the chart. */
+ shouldShow: boolean;
+};
+
+function VictoryChartExpandButton({onPress, shouldShow}: VictoryChartExpandButtonProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const icons = useMemoizedLazyExpensifyIcons(['Expand']);
+
+ return (
+
+ {/* Overlays the chart's top-right corner without affecting the chart's layout. When visible, the
+ button intentionally captures pointer events in that corner, suppressing any underlying chart
+ gesture layer — acceptable since charts render nothing interactive there. While hidden it
+ ignores pointer events entirely so an invisible button can never swallow presses. */}
+
+
+
+
+ );
+}
+
+VictoryChartExpandButton.displayName = 'VictoryChartExpandButton';
+
+export default VictoryChartExpandButton;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx
new file mode 100644
index 000000000000..efefabd6809e
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx
@@ -0,0 +1,154 @@
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants';
+import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext';
+import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor';
+import Modal from '@components/Modal';
+
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import CONST from '@src/CONST';
+
+import type {LayoutChangeEvent} from 'react-native';
+
+import React, {useState} from 'react';
+import {View} from 'react-native';
+
+import VictoryChartContent from './VictoryChartContent';
+
+type VictoryChartExpandModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** Called when the modal should close */
+ onClose: () => void;
+
+ /** Called once the modal's closing animation has finished */
+ onModalHide?: () => void;
+};
+
+/**
+ * Centered full-screen modal that re-renders the current chart scaled up to the viewport.
+ * Must be rendered inside a VictoryChartProvider so VictoryChartContent can read the parsed chart context.
+ *
+ * The chart is rendered at its design size and uniformly transform-scaled to fit the modal —
+ * the same technique the inline scaled container uses to shrink charts. This keeps the canvas
+ * and the absolutely-positioned label/legend overlays (whose coordinates are design-based)
+ * perfectly aligned, so the expanded chart looks identical to the inline one, only larger.
+ * Rendering fluidly instead would resize only the canvas and leave labels at design coordinates,
+ * misplacing them (and potentially overlaying the header, blocking the back button).
+ */
+function VictoryChartExpandModal({isVisible, onClose, onModalHide}: VictoryChartExpandModalProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext();
+ const [availableSize, setAvailableSize] = useState({width: 0, height: 0});
+
+ const onContainerLayout = (event: LayoutChangeEvent) => {
+ if (!isVisible) {
+ return;
+ }
+ const {width, height} = event.nativeEvent.layout;
+ // Avoid re-render churn when the layout callback fires without an actual size change.
+ setAvailableSize((prev) => (prev.width === width && prev.height === height ? prev : {width, height}));
+ };
+
+ const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined;
+ const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined;
+ const hasDesignDimensions = !!designWidth && !!designHeight;
+ const isMeasured = availableSize.width > 0 && availableSize.height > 0;
+
+ // Match the inline container: polar charts are clipped to hide the dead space at the
+ // bottom of their design canvas, so the expanded chart centers the same way inline does.
+ const isPolar = type === CHART_TYPE.POLAR;
+ const effectiveDesignHeight = designHeight !== undefined && isPolar ? designHeight * POLAR_CONTAINER_HEIGHT_RATIO : designHeight;
+
+ // Uniform scale that fits the chart's (clipped) design box inside the available modal area (may be > 1).
+ const scale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1;
+
+ // Visual styles parsed from the chart HTML — resolved and applied the same way
+ // VictoryChartContainerFixed does inline, so the expanded chart keeps the same
+ // (theme-aware) background and rounding.
+ const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme);
+ const borderRadius = chartContainerStyles.borderRadius;
+
+ return (
+
+ {/* Header matches the attachment modal: back button on narrow layouts, close button on the right otherwise. */}
+
+ {/* Padding lives on the outer view; the inner view is measured so the scale never
+ exceeds the actual content area and the side gutters are preserved. */}
+
+
+ {isMeasured &&
+ (hasDesignDimensions && effectiveDesignHeight !== undefined ? (
+ // Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity.
+
+ {/* Fixed design-size box so the fluid chart renders at design size, then scaled uniformly. */}
+
+
+
+
+ ) : (
+ // Charts without design dimensions have no design-based label coordinates, so fluid
+ // rendering is safe. Background/rounding are still applied so the expanded chart
+ // keeps the same themed container the inline fluid path renders with.
+
+
+
+ ))}
+
+
+
+ );
+}
+
+VictoryChartExpandModal.displayName = 'VictoryChartExpandModal';
+
+export default VictoryChartExpandModal;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandable.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandable.tsx
new file mode 100644
index 000000000000..e79c65d97332
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandable.tsx
@@ -0,0 +1,82 @@
+import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext';
+
+import useHover from '@hooks/useHover';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+
+import type {ReactNode} from 'react';
+
+import React, {useState} from 'react';
+import {View} from 'react-native';
+
+import VictoryChartExpandButton from './VictoryChartExpandButton';
+import VictoryChartExpandModal from './VictoryChartExpandModal';
+
+type VictoryChartExpandableProps = {
+ /** The inline chart to overlay the expand affordance on */
+ children: ReactNode;
+};
+
+/**
+ * Wraps an inline chart with the expand affordance: a hover-revealed button overlaid on the
+ * chart's top-right corner and a full-screen modal that renders the chart enlarged.
+ *
+ * Hover/expand state lives here — below the chart renderer — so the chart subtree (passed as
+ * `children` with a stable element identity) does not re-render on mouse enter/leave.
+ * The modal is only mounted after the first expand (and unmounted after its closing animation)
+ * because mounting a Modal per chart on the chat path would be needlessly expensive.
+ */
+function VictoryChartExpandable({children}: VictoryChartExpandableProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {chartContentStyles} = useVictoryChartContext();
+ const {hovered, deviceHasHoverSupport, bind: hoverBind} = useHover();
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [shouldRenderModal, setShouldRenderModal] = useState(false);
+
+ const openModal = () => {
+ setShouldRenderModal(true);
+ setIsExpanded(true);
+ };
+
+ // The inline chart never renders wider than its design width (inline scaling caps at 1). On web
+ // the wrapper shrinks to its content, but native Yoga stretches a width-less view to the full
+ // message column — which would detach the corner-anchored button from the chart on wide surfaces
+ // (tablets). Capping the wrapper at the design width keeps the anchor on the chart everywhere,
+ // while mw100 still lets it shrink in narrow containers (e.g. the side panel).
+ const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined;
+
+ // Devices can support both touch and hover (touchscreen laptops, tablets with a pointer). A touch
+ // interaction can't produce a hover, so the button must stay visible whenever touch is possible.
+ const shouldAlwaysShowButton = !deviceHasHoverSupport || canUseTouchScreen();
+
+ return (
+ <>
+
+ {children}
+ {/* Shown on hover only (like receipt actions) on hover-only devices; always shown when touch input is possible. */}
+
+
+ {shouldRenderModal && (
+ setIsExpanded(false)}
+ onModalHide={() => setShouldRenderModal(false)}
+ />
+ )}
+ >
+ );
+}
+
+VictoryChartExpandable.displayName = 'VictoryChartExpandable';
+
+export default VictoryChartExpandable;
diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts
index 69ff244c6d9e..a0532e941b5b 100644
--- a/src/hooks/useHover.ts
+++ b/src/hooks/useHover.ts
@@ -7,6 +7,7 @@ const useHover = () => {
const deviceHasHoverSupport = hasHoverSupport();
return {
hovered,
+ deviceHasHoverSupport,
bind: {
onMouseEnter: () => deviceHasHoverSupport && setHovered(true),
onMouseLeave: () => deviceHasHoverSupport && setHovered(false),
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 7df279c52bb7..b0233154a1f3 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -437,6 +437,7 @@ const translations: TranslationDeepObject = {
print: 'Drucken',
help: 'Hilfe',
collapsed: 'Eingeklappt',
+ expand: 'Erweitern',
expanded: 'Ausgeklappt',
expenseReport: 'Spesenabrechnung',
rateOutOfPolicy: 'Satz außerhalb der Richtlinie',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index de76bc16c162..30dbe8fde692 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -473,6 +473,7 @@ const translations = {
print: 'Print',
help: 'Help',
collapsed: 'Collapsed',
+ expand: 'Expand',
expanded: 'Expanded',
expenseReport: 'Expense Report',
// @context Rate as a noun, not a verb
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 599b4c59aeb6..6dd81973dd7d 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -389,6 +389,7 @@ const translations: TranslationDeepObject = {
print: 'Imprimir',
help: 'Ayuda',
collapsed: 'Contraído',
+ expand: 'Expandir',
expanded: 'Expandido',
expenseReport: 'Informe de Gastos',
rateOutOfPolicy: 'Tasa fuera de póliza',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 3fa2bff144b5..262c80dc5f81 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -437,6 +437,7 @@ const translations: TranslationDeepObject = {
print: 'Imprimer',
help: 'Aide',
collapsed: 'Réduit',
+ expand: 'Agrandir',
expanded: 'Développé',
expenseReport: 'Note de frais',
rateOutOfPolicy: 'Taux hors politique',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index c56c77becd0a..5429d4aa8c65 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -437,6 +437,7 @@ const translations: TranslationDeepObject = {
print: 'Stampa',
help: 'Aiuto',
collapsed: 'Comprresso',
+ expand: 'Espandi',
expanded: 'Espanso',
expenseReport: 'Nota spese',
rateOutOfPolicy: 'Tariffa fuori dalla policy',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 54d3597ae441..ff7d6ce32f67 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -436,6 +436,7 @@ const translations: TranslationDeepObject = {
print: '印刷',
help: 'ヘルプ',
collapsed: '折りたたみ',
+ expand: '展開',
expanded: '展開',
expenseReport: '経費精算書',
rateOutOfPolicy: 'ポリシー外の料率',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 822129182504..b9670883f605 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -436,6 +436,7 @@ const translations: TranslationDeepObject = {
print: 'Afdrukken',
help: 'Help',
collapsed: 'Ingeklapt',
+ expand: 'Uitklappen',
expanded: 'Uitgeklapt',
expenseReport: 'Declaratie',
rateOutOfPolicy: 'Tarief buiten beleid',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 0fb3ebda9bfa..1a7450544027 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -436,6 +436,7 @@ const translations: TranslationDeepObject = {
print: 'Drukuj',
help: 'Pomoc',
collapsed: 'Zwinięte',
+ expand: 'Rozwiń',
expanded: 'Rozwinięte',
expenseReport: 'Raport wydatków',
rateOutOfPolicy: 'Stawka poza zasadami',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 2d3490633fd8..86ff351c71d6 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -436,6 +436,7 @@ const translations: TranslationDeepObject = {
print: 'Imprimir',
help: 'Ajuda',
collapsed: 'Recolhido',
+ expand: 'Expandir',
expanded: 'Expandido',
expenseReport: 'Relatório de despesas',
rateOutOfPolicy: 'Tarifa fora da política',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 3c319b12f849..034041d0a217 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -436,6 +436,7 @@ const translations: TranslationDeepObject = {
print: '打印',
help: '帮助',
collapsed: '已折叠',
+ expand: '展开',
expanded: '已展开',
expenseReport: '报销报告',
rateOutOfPolicy: '超出政策的费率',
diff --git a/src/styles/index.ts b/src/styles/index.ts
index d302a2cac886..ab5ba80f0e8a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6347,6 +6347,9 @@ const staticStyles = (theme: ThemeColors) =>
chartContainer: {
borderRadius: variables.componentBorderRadiusLarge,
},
+ chartExpandedContent: {
+ transformOrigin: 'top left',
+ },
chartContent: {
height: CHART_CONTENT_MIN_HEIGHT,
},
diff --git a/tests/ui/BaseListItemTest.tsx b/tests/ui/BaseListItemTest.tsx
index 6f99081dcc2e..cd707a2a3534 100644
--- a/tests/ui/BaseListItemTest.tsx
+++ b/tests/ui/BaseListItemTest.tsx
@@ -16,6 +16,7 @@ describe('BaseListItem', () => {
const mouseLeaveMock = jest.fn();
mockedUseHover.mockReturnValue({
hovered: false,
+ deviceHasHoverSupport: true,
bind: {
onMouseEnter: mouseEnterMock,
onMouseLeave: mouseLeaveMock,
@@ -38,7 +39,7 @@ describe('BaseListItem', () => {
});
it('should use the accessibilityLabel prop as the row name when provided', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should fall back to the item-derived label when accessibilityLabel is omitted', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should keep the button role for a navigational row when shouldUseOptionRole is false', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should resolve a single-select row to the option role by default', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should be presentational (not a button) when accessible is false, so nested controls stay reachable', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should drive the row selected state from the isSelected prop when selection is not on the item', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(
{
});
it('should fall back to item.isSelected for the row selected state when the isSelected prop is omitted', () => {
- mockedUseHover.mockReturnValue({hovered: false, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
+ mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}});
render(