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(