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

Issue:
Add container to display the public key
### Context

Display the public key in a container to allow users to easily copy and import it into their wallet UI.

### Todos

- Add `Code` component

Pull request:
feat: add code container
<!--
Pull request titles should follow conventional commit format.
https://www.conventionalcommits.org/en/v1.0.0/
-->

## Context

<!--
What were you trying to do?
Provide further details about how the feature should be tested/reviewed if necessary.
If the PR is related to an open issue(s) please provide a list of them.

Example:
    - closes (or fixes) #<issue number>
    - closes (or fixes) #<issue number>
-->

Closes #94.

## Changes proposed in this pull request

<!--
Provide a succinct description of what this pull request entails.
-->

Introduces a `Code` container to display the public key, with a copy button, allowing users to easily copy the value directly from the container.

Patch:
diff --git a/src/components/button.tsx b/src/components/button.tsx
index 4566fc0..6d6f75a 100644
--- a/src/components/button.tsx
+++ b/src/components/button.tsx
@@ -16,9 +16,11 @@ const buttonVariants = cva(
       variant: {
         default: 'bg-button-base text-white hover:bg-button-base-hover',
         destructive: 'bg-error text-error hover:bg-error-hover',
+        ghost: '',
       },
       size: {
         default: 'py-4 px-6 font-medium',
+        icon: 'h-6 w-6',
       },
       fullWidth: {
         true: 'w-full',
diff --git a/src/components/code.tsx b/src/components/code.tsx
new file mode 100644
index 0000000..e82b0d1
--- /dev/null
+++ b/src/components/code.tsx
@@ -0,0 +1,55 @@
+import React from 'react'
+
+import { cn } from '@/utils/cn'
+
+import { Button } from './button'
+import { CheckIcon, ClipboardIcon } from './icons'
+
+interface CodeProps extends React.HTMLAttributes<HTMLDivElement> {
+  value: string
+}
+
+export const Code = ({ value, className, ...props }: CodeProps) => {
+  return (
+    <div
+      className={cn(
+        'flex items-center justify-between gap-x-2 rounded-xl bg-nav-active p-4 text-medium break-all',
+        className,
+      )}
+      {...props}>
+      <code>{value}</code>
+      <CopyButton value={value} />
+    </div>
+  )
+}
+
+interface CopyButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
+  value: string
+}
+
+const CopyButton = ({ value, ...props }: CopyButtonProps) => {
+  const [hasCopied, setHasCopied] = React.useState(false)
+
+  React.useEffect(() => {
+    if (hasCopied === true) {
+      setTimeout(() => {
+        setHasCopied(false)
+      }, 2000)
+    }
+  }, [hasCopied])
+
+  return (
+    <Button
+      {...props}
+      aria-label="copy"
+      variant="ghost"
+      size="icon"
+      className="text-primary rounded-sm"
+      onClick={() => {
+        navigator.clipboard.writeText(value)
+        setHasCopied(true)
+      }}>
+      {hasCopied ? <CheckIcon className="h-6 w-6" /> : <ClipboardIcon className="h-6 w-6" />}
+    </Button>
+  )
+}
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 11950a5..351d5e1 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -93,3 +93,35 @@ export const DollarSign = (props: React.SVGProps<SVGSVGElement>) => {
     </svg>
   )
 }
+
+export const ClipboardIcon = (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}>
+      <path
+        d="M9 18C8.45 18 7.97933 17.8043 7.588 17.413C7.196 17.021 7 16.55 7 16V4C7 3.45 7.196 2.979 7.588 2.587C7.97933 2.19567 8.45 2 9 2H18C18.55 2 19.021 2.19567 19.413 2.587C19.8043 2.979 20 3.45 20 4V16C20 16.55 19.8043 17.021 19.413 17.413C19.021 17.8043 18.55 18 18 18H9ZM9 16H18V4H9V16ZM5 22C4.45 22 3.979 21.8043 3.587 21.413C3.19567 21.021 3 20.55 3 20V6H5V20H16V22H5Z"
+        fill="currentColor"
+      />
+    </svg>
+  )
+}
+
+export const CheckIcon = (props: React.SVGProps<SVGSVGElement>) => {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={1.5}
+      stroke="currentColor"
+      className="w-6 h-6"
+      {...props}>
+      <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
+    </svg>
+  )
+}
diff --git a/src/popup/index.css b/src/popup/index.css
index ea53da1..8bd1f96 100644
--- a/src/popup/index.css
+++ b/src/popup/index.css
@@ -14,6 +14,7 @@
 
     /* Background colors */
     --bg-primary: 59 130 246;
+    --bg-nav-active: 226 232 240;
     --bg-error: 254 226 226;
     --bg-error-hover: 254 202 202;
     --bg-button-base: 86 183 181;
diff --git a/tailwind.config.ts b/tailwind.config.ts
index f44ecc3..2b9443c 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -21,6 +21,7 @@ module.exports = {
       backgroundColor: {
         primary: 'rgb(var(--bg-primary) / <alpha-value>)',
         error: 'rgb(var(--bg-error) / <alpha-value>)',
+        'nav-active': 'rgb(var(--bg-nav-active) / <alpha-value>)',
         'error-hover': 'rgb(var(--bg-error-hover) / <alpha-value>)',
         'button-base': 'rgb(var(--bg-button-base) / <alpha-value>)',
         'button-base-hover': 'rgb(var(--bg-button-base-hover) / <alpha-value>)',


Test patch:
diff --git a/src/components/__tests__/code.test.tsx b/src/components/__tests__/code.test.tsx
new file mode 100644
index 0000000..12f3bd1
--- /dev/null
+++ b/src/components/__tests__/code.test.tsx
@@ -0,0 +1,30 @@
+import { fireEvent, render } from '@testing-library/react'
+import React from 'react'
+
+import { Code } from '../code'
+
+describe('Code', () => {
+  it('should render the code component', () => {
+    const { queryByRole, container } = render(<Code value="test" />)
+    const code = container.querySelector('code')
+
+    expect(code).toBeInTheDocument()
+    expect(code).toHaveTextContent('test')
+    expect(queryByRole('button')).toHaveAttribute('aria-label', 'copy')
+  })
+
+  it('calls clipboard.writeText with the correct value', () => {
+    Object.assign(navigator, {
+      clipboard: {
+        writeText: jest.fn(),
+      },
+    })
+
+    const { getByRole } = render(<Code value="test" />)
+    const copyButton = getByRole('button')
+    expect(copyButton).toBeInTheDocument()
+
+    fireEvent.click(copyButton)
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test')
+  })
+})