Repository: interledger/web-monetization-extension
Pull request #1023 (2025-04-21 13:55:18)

fix(Input,InputAmount): support addons of dynamic size
<!--
Pull request titles should follow conventional commit format.
https://www.conventionalcommits.org/en/v1.0.0/
-->

## Context

Follow-up of https://github.com/interledger/web-monetization-extension/pull/1022 to support longer currencies in InputAmount (ConnectWalletForm).

## Changes proposed in this pull request

<!--
Provide a succinct description of what this pull request entails.
-->


github-actions[bot]: <!-- __WM_EXTENSION_BUILD_PREVIEWS__ -->
<h2>Extension builds preview</h2>

<table role="table">
<thead>
  <tr>
    <th>Name</th>
    <th>Link</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td align="center">Latest commit</td>
    <td align="center">5d8e9ebddd1ca20cbc135f200543e821056c1e93</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/14574699552">Run #14574699552</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (990.86KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/37452686550/artifacts/2979372209">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (990.89KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/37452686550/artifacts/2979371974">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/src/pages/popup/components/ConnectWalletForm.tsx b/src/pages/popup/components/ConnectWalletForm.tsx
index fa899d9..c5f117e 100644
--- a/src/pages/popup/components/ConnectWalletForm.tsx
+++ b/src/pages/popup/components/ConnectWalletForm.tsx
@@ -378,6 +378,7 @@ export const ConnectWalletForm = ({
             label={t('connectWallet_label_amount')}
             labelHidden={true}
             amount={amount}
+            className="max-w-48"
             walletAddress={
               walletAddressInfo || { assetCode: 'USD', assetScale: 2 }
             }
@@ -388,7 +389,6 @@ export const ConnectWalletForm = ({
               setErrors((prev) => ({ ...prev, amount: toErrorInfo(err) }));
             }}
             onChange={handleAmountChange}
-            className="max-w-32"
             placeholder="5.00"
           />
 
diff --git a/src/pages/popup/components/Settings/Budget.tsx b/src/pages/popup/components/Settings/Budget.tsx
index 47d77c4..e10ed3f 100644
--- a/src/pages/popup/components/Settings/Budget.tsx
+++ b/src/pages/popup/components/Settings/Budget.tsx
@@ -1,5 +1,4 @@
 import React from 'react';
-import { Input } from '@/pages/shared/components/ui/Input';
 import { Switch } from '@/pages/shared/components/ui/Switch';
 import { Button } from '@/pages/shared/components/ui/Button';
 import { InputAmount } from '@/pages/shared/components/InputAmount';
@@ -9,7 +8,6 @@ import {
   getNextOccurrence,
   transformBalance,
 } from '@/shared/helpers';
-import { getCurrencySymbol } from '@/pages/shared/lib/utils';
 import { useMessage, useTranslation } from '@/popup/lib/context';
 import type { Response, UpdateBudgetPayload } from '@/shared/messages';
 import type { PopupState } from '@/popup/lib/store';
@@ -216,16 +214,15 @@ const RemainingBalance = ({
   const amount = transformBalance(balance, walletAddress.assetScale);
   return (
     <div className="space-y-2">
-      <Input
+      <InputAmount
+        id="remainingBalance"
+        onChange={() => {}}
+        onError={() => {}}
         label="Remaining balance"
         className="max-w-56"
-        leadingAddOn={
-          <span className="text-weak">
-            {getCurrencySymbol(walletAddress.assetCode)}
-          </span>
-        }
-        value={amount}
-        disabled={true}
+        walletAddress={walletAddress}
+        amount={amount}
+        readOnly={true}
       />
     </div>
   );
diff --git a/src/pages/popup/components/Settings/RateOfPay.tsx b/src/pages/popup/components/Settings/RateOfPay.tsx
index 84287dc..2dd31fc 100644
--- a/src/pages/popup/components/Settings/RateOfPay.tsx
+++ b/src/pages/popup/components/Settings/RateOfPay.tsx
@@ -117,7 +117,6 @@ const RateOfPayInput = ({
       <InputAmount
         id="rateOfPay"
         className="max-w-56"
-        wrapperClassName="w-fit"
         label="Rate of pay per hour"
         walletAddress={walletAddress}
         onChange={(value) => {
diff --git a/src/pages/shared/components/InputAmount.tsx b/src/pages/shared/components/InputAmount.tsx
index d1a2c7b..0769e3c 100644
--- a/src/pages/shared/components/InputAmount.tsx
+++ b/src/pages/shared/components/InputAmount.tsx
@@ -1,7 +1,12 @@
 import React from 'react';
 import { Input } from './ui/Input';
 import type { WalletAddress } from '@interledger/open-payments';
-import { formatNumber, formatCurrency, getCurrencySymbol } from '../lib/utils';
+import {
+  formatNumber,
+  formatCurrency,
+  getCurrencySymbol,
+  cn,
+} from '../lib/utils';
 import { errorWithKey, type ErrorWithKeyLike } from '@/shared/helpers';
 import { useLongPress, useThrottle } from '@/pages/shared/lib/hooks';
 
@@ -14,7 +19,6 @@ interface Props {
   onChange: (amount: string, inputEl: HTMLInputElement) => void;
   onError: (error: ErrorWithKeyLike) => void;
   className?: string;
-  wrapperClassName?: string;
   placeholder?: string;
   errorMessage?: string;
   readOnly?: boolean;
@@ -30,8 +34,7 @@ export const InputAmount = ({
   id,
   walletAddress,
   amount,
-  className,
-  wrapperClassName,
+  className: wrapperClassName,
   placeholder,
   errorMessage,
   onChange,
@@ -130,11 +133,21 @@ export const InputAmount = ({
       aria-label={labelHidden && typeof label === 'string' ? label : undefined}
       description={description}
       placeholder={placeholder}
-      className={className}
       wrapperClassName={wrapperClassName}
       defaultValue={amount}
       readOnly={readOnly}
-      leadingAddOn={<span className="text-weak">{currencySymbol}</span>}
+      leadingAddOn={
+        <span
+          className={cn(
+            'overflow-hidden block text-ellipsis text-weak whitespace-nowrap',
+            walletAddress.assetCode.length !== 3 && 'font-normal font-mono',
+          )}
+          style={{ maxWidth: '6ch', paddingLeft: '1ch' }}
+          title={`Currency: ${walletAddress.assetCode.toUpperCase()}`}
+        >
+          {currencySymbol}
+        </span>
+      }
       trailingAddOn={
         controls ? (
           <Controls readOnly={readOnly} inc={controlInc} dec={controlDec} />
diff --git a/src/pages/shared/components/ui/Input.tsx b/src/pages/shared/components/ui/Input.tsx
index 21da5a6..1c36341 100644
--- a/src/pages/shared/components/ui/Input.tsx
+++ b/src/pages/shared/components/ui/Input.tsx
@@ -1,19 +1,19 @@
+import React from 'react';
 import { type VariantProps, cva } from 'class-variance-authority';
-import React, { forwardRef } from 'react';
 import { cn } from '@/pages/shared/lib/utils';
 import { Label } from '@/pages/shared/components/ui/Label';
 
 const inputVariants = cva(
   [
-    'h-14 w-full rounded-xl border border-2 px-4 text-base text-medium',
-    'focus:border-focus focus:outline-none',
-    'placeholder:text-disabled',
+    'table w-full rounded-xl overflow-hidden',
+    'border border-2 border-transparent focus-within:border-focus',
+    'text-medium',
   ],
 
   {
     variants: {
       variant: {
-        default: 'border-base',
+        default: 'border-base text-medium',
       },
       disabled: {
         true: 'cursor-default border-transparent bg-disabled text-disabled',
@@ -21,6 +21,9 @@ const inputVariants = cva(
       readOnly: {
         true: 'cursor-default border-transparent bg-disabled text-disabled',
       },
+      error: {
+        true: 'border-error',
+      },
     },
     defaultVariants: {
       variant: 'default',
@@ -39,25 +42,24 @@ export interface InputProps
   label?: React.ReactNode;
   description?: React.ReactNode;
   wrapperClassName?: string;
+  ref?: React.RefObject<HTMLInputElement | null>;
 }
 
-export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
-  {
-    type = 'text',
-    leadingAddOn,
-    trailingAddOn,
-    label,
-    description,
-    errorMessage,
-    disabled,
-    readOnly,
-    className,
-    wrapperClassName,
-    id,
-    ...props
-  },
+export function Input({
+  type = 'text',
+  leadingAddOn,
+  trailingAddOn,
+  label,
+  description,
+  errorMessage,
+  disabled,
+  readOnly,
+  className,
+  wrapperClassName,
+  id,
   ref,
-) {
+  ...props
+}: InputProps) {
   const randomId = React.useId();
   id ||= randomId; // cannot call useId conditionally, but use randomId only if default not provided
 
@@ -69,21 +71,25 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
           {description}
         </p>
       ) : null}
-      <div className={cn('relative', wrapperClassName)}>
+      <div
+        className={cn(
+          inputVariants({ disabled, readOnly, error: !!errorMessage }),
+          wrapperClassName,
+        )}
+      >
         {leadingAddOn ? (
-          <div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center text-sm font-medium">
-            {leadingAddOn}
-          </div>
+          <InputAddon inputRef={ref}>{leadingAddOn}</InputAddon>
         ) : null}
         <input
           id={id}
           ref={ref}
           type={type}
           className={cn(
-            inputVariants({ disabled, readOnly }),
-            leadingAddOn && 'pl-10',
-            trailingAddOn && 'pr-10',
-            errorMessage && 'border-error',
+            'border-none focus:border-none focus:ring-0 focus:outline-none',
+            'table-cell w-full py-3.5 text-base',
+            'text-inherit bg-inherit',
+            'placeholder:text-disabled',
+            leadingAddOn ? 'px-0' : 'px-4',
             className,
           )}
           disabled={disabled}
@@ -94,9 +100,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
           {...props}
         />
         {trailingAddOn ? (
-          <div className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-sm font-medium">
-            {trailingAddOn}
-          </div>
+          <InputAddon inputRef={ref}>{trailingAddOn}</InputAddon>
         ) : null}
       </div>
       {errorMessage && (
@@ -104,4 +108,24 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
       )}
     </div>
   );
-});
+}
+
+function InputAddon({
+  children,
+  inputRef,
+}: React.PropsWithChildren<{
+  inputRef?: React.RefObject<HTMLInputElement | null>;
+}>) {
+  return (
+    // biome-ignore lint/a11y/useKeyWithClickEvents: only need to handle click on prefix/suffix to trigger input focus
+    <div
+      className={cn(
+        'whitespace-nowrap table-cell align-middle select-none w-[1%]',
+        'text-sm font-medium cursor-text px-2',
+      )}
+      onClick={() => inputRef?.current?.focus()}
+    >
+      {children}
+    </div>
+  );
+}


Test patch:
diff --git a/src/pages/shared/components/ui/__tests__/Input.test.tsx b/src/pages/shared/components/ui/__tests__/Input.test.tsx
index 41b1f13..32355dd 100644
--- a/src/pages/shared/components/ui/__tests__/Input.test.tsx
+++ b/src/pages/shared/components/ui/__tests__/Input.test.tsx
@@ -26,26 +26,31 @@ describe('Input', () => {
   it('should have the `border-base` class by default', () => {
     const { queryByLabelText } = render(<Input aria-label="test input" />);
 
-    expect(queryByLabelText('test input')).toBeInTheDocument();
-    expect(queryByLabelText('test input')).toHaveClass('border-base');
+    const input = queryByLabelText('test input')!;
+    expect(input).toBeInTheDocument();
+    expect(input.closest('div')).toHaveClass('border-base');
   });
 
-  it('should have the `pl-10` class when the `leadingAddOn` variant is passed', () => {
+  it('should display `leadingAddOn`', () => {
     const { queryByLabelText } = render(
       <Input aria-label="test input" leadingAddOn="$" />,
     );
 
-    expect(queryByLabelText('test input')).toBeInTheDocument();
-    expect(queryByLabelText('test input')).toHaveClass('pl-10');
+    const input = queryByLabelText('test input')!;
+    expect(input).toBeInTheDocument();
+    expect(input.previousElementSibling).toHaveTextContent('$');
+    expect(input.nextElementSibling).toBeNull();
   });
 
-  it('should have the `pr-10` class when the `trailingAddon` variant is passed', () => {
+  it('should display the `trailingAddon`', () => {
     const { queryByLabelText } = render(
       <Input aria-label="test input" trailingAddOn="$" />,
     );
 
-    expect(queryByLabelText('test input')).toBeInTheDocument();
-    expect(queryByLabelText('test input')).toHaveClass('pr-10');
+    const input = queryByLabelText('test input')!;
+    expect(input).toBeInTheDocument();
+    expect(input.nextElementSibling).toHaveTextContent('$');
+    expect(input.previousElementSibling).toBeNull();
   });
 
   it('should have the `bg-disabled` and `border-transparent` classes when the `disabled` variant is passed', () => {
@@ -53,9 +58,11 @@ describe('Input', () => {
       <Input aria-label="test input" disabled />,
     );
 
-    expect(queryByLabelText('test input')).toBeInTheDocument();
-    expect(queryByLabelText('test input')).toHaveClass('bg-disabled');
-    expect(queryByLabelText('test input')).toHaveClass('border-transparent');
+    const input = queryByLabelText('test input')!;
+    expect(input).toBeInTheDocument();
+    const wrapper = input.closest('div');
+    expect(wrapper).toHaveClass('bg-disabled');
+    expect(wrapper).toHaveClass('border-transparent');
   });
 
   it('should have the `aria-invalid` and `aria-describedby` attributes if errorMessage is present', () => {