Repository: interledger/web-monetization-extension (ts)

Issue:
Add input component
### Context

- #44

### Todos

- Implement a reusable input component (text)
- For the amount input, we can use `react-number-format`
- The component should have handling for focus, disabled and other required states


> [!NOTE]  
> We can have two separate input components (text and amount).
> A separate label component can be added, which can be used for other form items as well.

Pull request description:
feat: input and label components
Closes #50 

## Changes proposed in this pull request

- add reusable input and label component as per graphic design
- add input unit tests


Patch:
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 555791c..ee34cb2 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -17,3 +17,35 @@ export const Spinner = (props: React.SVGProps<SVGSVGElement>) => {
     </svg>
   )
 }
+
+export const DollarSign = (props: React.SVGProps<SVGSVGElement>) => {
+  return (
+    <svg
+      width="24"
+      height="24"
+      viewBox="0 0 24 24"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+      {...props}>
+      <g id="attach_money">
+        <mask
+          id="mask0_140_3168"
+          style={{ maskType: 'alpha' }}
+          maskUnits="userSpaceOnUse"
+          x="0"
+          y="0"
+          width="24"
+          height="24">
+          <rect id="Bounding box" width="24" height="24" fill="#D9D9D9" />
+        </mask>
+        <g mask="url(#mask0_140_3168)">
+          <path
+            id="attach_money_2"
+            d="M11.0252 21V18.85C10.1419 18.65 9.37953 18.2667 8.7382 17.7C8.0962 17.1333 7.6252 16.3333 7.3252 15.3L9.1752 14.55C9.4252 15.35 9.7962 15.9583 10.2882 16.375C10.7795 16.7917 11.4252 17 12.2252 17C12.9085 17 13.4879 16.846 13.9632 16.538C14.4379 16.2293 14.6752 15.75 14.6752 15.1C14.6752 14.5167 14.4919 14.054 14.1252 13.712C13.7585 13.3707 12.9085 12.9833 11.5752 12.55C10.1419 12.1 9.15853 11.5627 8.6252 10.938C8.09186 10.3127 7.8252 9.55 7.8252 8.65C7.8252 7.56667 8.1752 6.725 8.8752 6.125C9.5752 5.525 10.2919 5.18333 11.0252 5.1V3H13.0252V5.1C13.8585 5.23333 14.5462 5.53733 15.0882 6.012C15.6295 6.48733 16.0252 7.06667 16.2752 7.75L14.4252 8.55C14.2252 8.01667 13.9419 7.61667 13.5752 7.35C13.2085 7.08333 12.7085 6.95 12.0752 6.95C11.3419 6.95 10.7835 7.11267 10.4002 7.438C10.0169 7.76267 9.8252 8.16667 9.8252 8.65C9.8252 9.2 10.0752 9.63333 10.5752 9.95C11.0752 10.2667 11.9419 10.6 13.1752 10.95C14.3252 11.2833 15.1962 11.8123 15.7882 12.537C16.3795 13.2623 16.6752 14.1 16.6752 15.05C16.6752 16.2333 16.3252 17.1333 15.6252 17.75C14.9252 18.3667 14.0585 18.75 13.0252 18.9V21H11.0252Z"
+            fill="#475569"
+          />
+        </g>
+      </g>
+    </svg>
+  )
+}
diff --git a/src/components/input.tsx b/src/components/input.tsx
new file mode 100644
index 0000000..c98ec04
--- /dev/null
+++ b/src/components/input.tsx
@@ -0,0 +1,56 @@
+import { type VariantProps, cva } from 'class-variance-authority'
+import React, { forwardRef } from 'react'
+
+import { cn } from '@/utils/cn'
+
+const inputVariants = cva(
+  [
+    'w-full h-14 rounded-xl border border-2 px-4 text-base text-medium',
+    'focus:outline-none focus:border-focus',
+    'placeholder-disabled',
+  ],
+
+  {
+    variants: {
+      variant: {
+        default: 'border-base',
+      },
+      disabled: {
+        true: 'bg-disabled border-transparent',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+    },
+  },
+)
+
+export interface InputProps
+  extends VariantProps<typeof inputVariants>,
+    React.InputHTMLAttributes<HTMLInputElement> {
+  errorMessage?: string
+  disabled?: boolean
+  icon?: React.ReactNode
+}
+
+export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
+  { type = 'text', icon, errorMessage, disabled, className, ...props },
+  ref,
+) {
+  return (
+    <div className="relative">
+      {icon && <div className="absolute left-4 top-4">{icon}</div>}
+      <input
+        ref={ref}
+        type={type}
+        className={cn(inputVariants({ disabled }), icon && 'pl-12', className)}
+        disabled={disabled ?? false}
+        aria-disabled={disabled ?? false}
+        aria-invalid={!!errorMessage}
+        aria-describedby={errorMessage}
+        {...props}
+      />
+      {errorMessage && <p className="text-error text-sm px-2">{errorMessage}</p>}
+    </div>
+  )
+})
diff --git a/src/components/label.tsx b/src/components/label.tsx
new file mode 100644
index 0000000..94f6063
--- /dev/null
+++ b/src/components/label.tsx
@@ -0,0 +1,23 @@
+import { type VariantProps, cva } from 'class-variance-authority'
+import React, { forwardRef } from 'react'
+
+import { cn } from '@/utils/cn'
+
+const labelVariants = cva('text-medium font-medium leading-6 px-2 flex items-center gap-2')
+
+export interface LabelProps
+  extends VariantProps<typeof labelVariants>,
+    React.LabelHTMLAttributes<HTMLLabelElement> {
+  children: React.ReactNode
+}
+
+export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
+  { className, children, ...props },
+  ref,
+) {
+  return (
+    <label ref={ref} className={cn(labelVariants(), className)} {...props}>
+      {children}
+    </label>
+  )
+})
diff --git a/src/popup/index.css b/src/popup/index.css
index e78227f..ea53da1 100644
--- a/src/popup/index.css
+++ b/src/popup/index.css
@@ -25,7 +25,8 @@
     /* Border colors */
     --border-base: 203 213 225;
     --border-focus: 59 130 246;
-    
+    --border-error: 220 38 38;
+
     /* Popup */
     --popup-width: 448px;
     --popup-height: 559px;
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 62d3ee1..43ba8ef 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -32,6 +32,7 @@ module.exports = {
         base: 'rgb(var(--border-base) / <alpha-value>)',
         popup: 'rgb(var(--border-popup) / <alpha-value>)',
         focus: 'rgb(var(--border-focus) / <alpha-value>)',
+        error: 'rgb(var(--border-error) / <alpha-value>)',
       },
     },
   },


Test patch:
diff --git a/src/components/__tests__/input.test.tsx b/src/components/__tests__/input.test.tsx
new file mode 100644
index 0000000..6f66e7a
--- /dev/null
+++ b/src/components/__tests__/input.test.tsx
@@ -0,0 +1,55 @@
+import { render } from '@testing-library/react'
+import React from 'react'
+
+import { Input } from '@/components/input'
+
+describe('Input', () => {
+  it('should default to `type="text"`', () => {
+    const { queryByLabelText } = render(<Input aria-label="test input" />)
+
+    expect(queryByLabelText('test input')).toBeInTheDocument()
+    expect(queryByLabelText('test input')).toHaveAttribute('type', 'text')
+  })
+
+  it('should not have the `disabled` attribute and `aria-disabled="false"` if `loading` is false', () => {
+    const { queryByLabelText } = render(<Input aria-label="test input" />)
+
+    expect(queryByLabelText('test input')).toBeInTheDocument()
+    expect(queryByLabelText('test input')).not.toHaveAttribute('disabled')
+    expect(queryByLabelText('test input')).toHaveAttribute('aria-disabled', 'false')
+    expect(queryByLabelText('test input')).not.toBeDisabled()
+  })
+
+  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')
+  })
+
+  it('should have the `pl-12` class when the `icon` variant is passed', () => {
+    const { queryByLabelText } = render(<Input aria-label="test input" icon={<div />} />)
+
+    expect(queryByLabelText('test input')).toBeInTheDocument()
+    expect(queryByLabelText('test input')).toHaveClass('pl-12')
+  })
+
+  it('should have the `bg-disabled` and `border-transparent` classes when the `disabled` variant is passed', () => {
+    const { queryByLabelText } = render(<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')
+  })
+
+  it('should have the `aria-invalid` and `aria-describedby` attributes if errorMessage is present', () => {
+    const { queryByLabelText, queryByText } = render(
+      <Input aria-label="test input" errorMessage="some error" />,
+    )
+
+    expect(queryByLabelText('test input')).toBeInTheDocument()
+    expect(queryByLabelText('test input')).toHaveAttribute('aria-invalid')
+    expect(queryByLabelText('test input')).toHaveAttribute('aria-describedby')
+    expect(queryByText('some error')).toBeInTheDocument()
+  })
+})