From cd6839af22011f3ede3aa94591fbeb35daa8bd77 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 16:57:22 +0100 Subject: [PATCH 1/3] Update Budget component sizing --- .../CollaborativeBudgetField.tsx | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx index f38c9e4c2..d5eab3e33 100644 --- a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx +++ b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx @@ -17,11 +17,6 @@ const CURRENCY_SYMBOLS: Record = { USD: DEFAULT_CURRENCY_SYMBOL, }; -/** Formats a number as a locale-aware currency string (e.g. 5000 → "$5,000") */ -function formatBudgetDisplay(amount: number, currencySymbol: string): string { - return `${currencySymbol}${amount.toLocaleString()}`; -} - interface CollaborativeBudgetFieldProps { maxAmount?: number; initialValue?: BudgetData | null; @@ -33,8 +28,9 @@ interface CollaborativeBudgetFieldProps { * Stores `MoneyAmount` (`{ amount, currency }`) as a JSON string in the shared doc * for future multi-currency support. * - * Displays as a pill when a value exists, switching to an inline - * NumberField on click for editing. + * Displays as a pill when a value exists or empty, switching to an inline + * NumberField on click for editing. The pill width matches the input width + * to prevent layout shifts. */ export function CollaborativeBudgetField({ maxAmount, @@ -73,10 +69,22 @@ export function CollaborativeBudgetField({ CURRENCY_SYMBOLS[budget?.currency ?? DEFAULT_CURRENCY] ?? DEFAULT_CURRENCY_SYMBOL; - // Auto-focus when switching to edit mode + // Track the NumberField width so the pill button can match it + const [fieldWidth, setFieldWidth] = useState(0); + + // Auto-focus when switching to edit mode, and measure the field width + // after the NumberField's internal effects have settled useEffect(() => { if (isEditing && budgetInputRef.current) { budgetInputRef.current.focus(); + // Defer measurement so NumberField's value and prefix effects settle + const frame = requestAnimationFrame(() => { + const group = budgetInputRef.current?.closest('[role="group"]'); + if (group instanceof HTMLElement && group.offsetWidth > 0) { + setFieldWidth(group.offsetWidth); + } + }); + return () => cancelAnimationFrame(frame); } }, [isEditing]); @@ -111,40 +119,36 @@ export function CollaborativeBudgetField({ setIsEditing(false); }; - // No value and not editing → "Add budget" pill - if (budgetAmount === null && !isEditing) { - return ( - - ); - } - - // Has a value and not editing → display as pill - if (budgetAmount !== null && !isEditing) { + if (isEditing) { return ( - + ); } - // Editing mode → inline NumberField return ( - + ); } From b922dd61a7633bbcb7c032a0030bcb54909cfc3b Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 17:21:23 +0100 Subject: [PATCH 2/3] Use Intl.format --- .../CollaborativeBudgetField.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx index d5eab3e33..f385ba008 100644 --- a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx +++ b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx @@ -11,11 +11,18 @@ import { useTranslations } from '@/lib/i18n'; import { useCollaborativeDoc } from './CollaborativeDocContext'; const DEFAULT_CURRENCY = 'USD'; -const DEFAULT_CURRENCY_SYMBOL = '$'; -const CURRENCY_SYMBOLS: Record = { - USD: DEFAULT_CURRENCY_SYMBOL, -}; +const getCurrencySymbol = (currency: string) => + (0) + .toLocaleString(undefined, { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .replace(/\d/g, '') + .trim(); interface CollaborativeBudgetFieldProps { maxAmount?: number; @@ -65,9 +72,9 @@ export function CollaborativeBudgetField({ const [isEditing, setIsEditing] = useState(false); const budgetAmount = budget?.amount ?? null; - const currencySymbol = - CURRENCY_SYMBOLS[budget?.currency ?? DEFAULT_CURRENCY] ?? - DEFAULT_CURRENCY_SYMBOL; + const currencySymbol = getCurrencySymbol( + budget?.currency ?? DEFAULT_CURRENCY, + ); // Track the NumberField width so the pill button can match it const [fieldWidth, setFieldWidth] = useState(0); @@ -147,7 +154,12 @@ export function CollaborativeBudgetField({ style={fieldWidth > 0 ? { minWidth: fieldWidth } : undefined} > {budgetAmount !== null - ? `${currencySymbol}${budgetAmount.toLocaleString()}` + ? budgetAmount.toLocaleString(undefined, { + style: 'currency', + currency: budget?.currency ?? DEFAULT_CURRENCY, + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: 0, + }) : t('Add budget')} ); From 5f81404764d95e8f9df06d44c7225666b6d3d9de Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 17:56:33 +0100 Subject: [PATCH 3/3] Simplify code and small fixes --- .../CollaborativeBudgetField.tsx | 128 ++++++++++-------- 1 file changed, 74 insertions(+), 54 deletions(-) diff --git a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx index f385ba008..2443a6b56 100644 --- a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx +++ b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx @@ -4,7 +4,7 @@ import { useCollaborativeFragment } from '@/hooks/useCollaborativeFragment'; import type { BudgetData } from '@op/common/client'; import { Button } from '@op/ui/Button'; import { NumberField } from '@op/ui/NumberField'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from '@/lib/i18n'; @@ -72,26 +72,43 @@ export function CollaborativeBudgetField({ const [isEditing, setIsEditing] = useState(false); const budgetAmount = budget?.amount ?? null; - const currencySymbol = getCurrencySymbol( - budget?.currency ?? DEFAULT_CURRENCY, - ); + const currency = budget?.currency ?? DEFAULT_CURRENCY; + const currencySymbol = useMemo(() => getCurrencySymbol(currency), [currency]); + + const placeholderText = maxAmount + ? t('Max {amount}', { amount: maxAmount.toLocaleString() }) + : t('Enter amount'); + + // Size the input to its placeholder text instead of the default size=20 + useLayoutEffect(() => { + if (budgetInputRef.current) { + budgetInputRef.current.size = placeholderText.length; + } + }, [placeholderText]); - // Track the NumberField width so the pill button can match it - const [fieldWidth, setFieldWidth] = useState(0); + // Use the larger of the input and button natural widths so both match + const buttonRef = useRef(null); + const [sharedWidth, setSharedWidth] = useState(0); + + useEffect(() => { + if (isEditing) { + return; + } + const frame = requestAnimationFrame(() => { + const group = budgetInputRef.current?.closest('[role="group"]'); + const inputW = group instanceof HTMLElement ? group.offsetWidth : 0; + const buttonW = buttonRef.current?.scrollWidth ?? 0; + const width = Math.max(inputW, buttonW); + if (width > 0) { + setSharedWidth(width); + } + }); + return () => cancelAnimationFrame(frame); + }, [isEditing]); - // Auto-focus when switching to edit mode, and measure the field width - // after the NumberField's internal effects have settled useEffect(() => { if (isEditing && budgetInputRef.current) { budgetInputRef.current.focus(); - // Defer measurement so NumberField's value and prefix effects settle - const frame = requestAnimationFrame(() => { - const group = budgetInputRef.current?.closest('[role="group"]'); - if (group instanceof HTMLElement && group.offsetWidth > 0) { - setFieldWidth(group.offsetWidth); - } - }); - return () => cancelAnimationFrame(frame); } }, [isEditing]); @@ -100,14 +117,14 @@ export function CollaborativeBudgetField({ setBudget(null); } else { setBudget({ - currency: budget?.currency ?? DEFAULT_CURRENCY, + currency, amount: value, }); } }; useEffect(() => { - const emitted: BudgetData | null = budget; + const emitted = budgetText ? (JSON.parse(budgetText) as BudgetData) : null; const key = emitted ? `${emitted.amount}:${emitted.currency}` : null; if (lastEmittedRef.current === key) { @@ -116,7 +133,7 @@ export function CollaborativeBudgetField({ lastEmittedRef.current = key ?? undefined; onChangeRef.current?.(emitted); - }, [budget]); + }, [budgetText]); const handleStartEditing = () => { setIsEditing(true); @@ -126,41 +143,44 @@ export function CollaborativeBudgetField({ setIsEditing(false); }; - if (isEditing) { - return ( - - ); - } - return ( - + <> +
0 ? { minWidth: sharedWidth } : undefined} + > + +
+ {!isEditing && ( + + )} + ); }