Files
actual-actualbudget/packages/desktop-client/src/components/reports/FormulaResult.tsx
Matt Fiddaman a8ec84ceac stop font size fluctuations showing in summary cards (#7092)
* stop summary cards from showing until font size settled

* note
2026-03-06 23:00:41 +00:00

197 lines
5.4 KiB
TypeScript

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { Ref, RefObject } from 'react';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import debounce from 'lodash/debounce';
import { amountToInteger } from 'loot-core/shared/util';
import { LoadingIndicator } from './LoadingIndicator';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useResizeObserver } from '@desktop-client/hooks/useResizeObserver';
const FONT_SIZE_SCALE_FACTOR = 1.6;
const CONTAINER_MARGIN = 8;
type FormulaResultProps = {
value: number | string | null;
animate?: boolean;
loading?: boolean;
error?: string | null;
initialFontSize?: number;
fontSizeChanged?: (fontSize: number) => void;
fontSizeMode?: 'dynamic' | 'static';
staticFontSize?: number;
customColor?: string | null;
containerRef?: RefObject<HTMLDivElement | null>;
};
export function FormulaResult({
value,
animate = false,
loading = true,
error = null,
initialFontSize = 14,
fontSizeChanged,
fontSizeMode = 'dynamic',
staticFontSize = 32,
customColor = null,
containerRef,
}: FormulaResultProps) {
const [fontSize, setFontSize] = useState<number>(initialFontSize);
const [hasSized, setHasSized] = useState(false);
const refDiv = useRef<HTMLDivElement>(null);
const previousFontSizeRef = useRef<number>(initialFontSize);
const format = useFormat();
// Format the display value - just show what we got
const displayValue = useMemo(() => {
if (error) {
return error;
} else if (value === null || value === undefined) {
return '';
} else if (typeof value === 'number') {
return format(
amountToInteger(value, format.currency.decimalPlaces),
'financial',
);
} else {
return String(value);
}
}, [error, value, format]);
const calculateFontSize = useCallback(() => {
if (!refDiv.current) return;
const elementToMeasure =
containerRef?.current || refDiv.current.parentElement || refDiv.current;
const { clientWidth, clientHeight } = elementToMeasure;
const width = clientWidth;
const height = clientHeight - CONTAINER_MARGIN * 2;
if (width <= 0 || height <= 0) return;
// Get the actual display value length at calculation time
const valueLength = displayValue.length || 1; // Avoid division by zero
const calculatedFontSize = Math.min(
(width * FONT_SIZE_SCALE_FACTOR) / valueLength,
height, // Ensure the text fits vertically by using the height as the limiting factor
);
if (calculatedFontSize > 0) {
setFontSize(calculatedFontSize);
setHasSized(true);
}
// Only call fontSizeChanged if the font size actually changed
if (
fontSizeChanged &&
Math.abs(calculatedFontSize - previousFontSizeRef.current) > 0.5
) {
previousFontSizeRef.current = calculatedFontSize;
fontSizeChanged(calculatedFontSize);
}
}, [displayValue, fontSizeChanged, containerRef]);
// Debounce the calculation to avoid too many recalculations
const debouncedCalculateFontSize = useRef(
debounce(() => {
if (fontSizeMode === 'dynamic') {
calculateFontSize();
}
}, 100),
);
// Update the debounced function when calculateFontSize changes
useEffect(() => {
debouncedCalculateFontSize.current.cancel?.();
debouncedCalculateFontSize.current = debounce(() => {
if (fontSizeMode === 'dynamic') {
calculateFontSize();
}
}, 100);
return () => {
debouncedCalculateFontSize.current.cancel?.();
};
}, [calculateFontSize, fontSizeMode]);
const ref = useResizeObserver(() => {
if (fontSizeMode === 'dynamic') {
debouncedCalculateFontSize.current();
}
});
const mergedRef = useMergedRefs(ref, refDiv);
// Recalculate font size when displayValue changes (non-debounced for immediate update)
// Only for dynamic mode
useEffect(() => {
if (fontSizeMode === 'dynamic') {
calculateFontSize();
}
}, [displayValue, calculateFontSize, fontSizeMode]);
// Use static font size when in static mode
useEffect(() => {
if (fontSizeMode === 'static') {
setFontSize(staticFontSize);
setHasSized(true);
}
}, [fontSizeMode, staticFontSize]);
// Determine color
const color = customColor
? customColor
: error
? theme.errorText
: theme.pageText;
const showContent = hasSized || fontSizeMode === 'static';
return (
<View style={{ flex: 1 }}>
{loading && <LoadingIndicator />}
{!loading && (
<View
ref={mergedRef as Ref<HTMLDivElement>}
aria-label={displayValue}
style={{
alignItems: 'center',
flexGrow: 1,
flexShrink: 1,
width: '100%',
height: '100%',
maxWidth: '100%',
fontSize,
lineHeight: 1,
margin: `${CONTAINER_MARGIN}px 0`,
justifyContent: 'center',
transition: animate ? 'font-size 0.3s ease' : '',
color,
}}
>
{!showContent ? (
<LoadingIndicator />
) : (
<span aria-hidden="true">
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
)}
</View>
)}
</View>
);
}