Repository: interledger/web-monetization-extension
Pull request #73 (2024-01-10 10:21:43)

feat: Radio group implementation
Implements a radio group component with according styling for the radio buttons

raducristianpopa: <!-- __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">055153041a769470432edb9348b34a7a7b0dd423</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/7473467730">Run #7473467730</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (106.64KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/19662620730/artifacts/1159116185">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (106.71KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/19662620730/artifacts/1159116188">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Opera (106.61KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/19662620730/artifacts/1159116192">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Edge (106.61KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/19662620730/artifacts/1159116186">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index ca9b8c9..01f96c6 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -68,7 +68,14 @@
     "react/jsx-props-no-spreading": "off",
     "import/prefer-default-export": "off",
     "jsx-a11y/anchor-is-valid": "warn",
-    "jsx-a11y/no-noninteractive-tabindex": "warn",
+    "jsx-a11y/no-noninteractive-tabindex": [
+      "warn",
+      {
+        "tags": [],
+        "roles": ["tabpanel"],
+        "allowExpressionValues": true
+      }
+    ],
     "jsx-a11y/tabindex-no-positive": "warn",
     "jsx-a11y/click-events-have-key-events": "warn",
     "jsx-a11y/no-static-element-interactions": "warn",
diff --git a/package.json b/package.json
index f24d290..7cb1f3b 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
   },
   "devDependencies": {
     "@interledger/http-signature-utils": "^2.0.0",
+    "@tailwindcss/forms": "^0.5.7",
     "@testing-library/jest-dom": "^6.1.3",
     "@testing-library/react": "^14.0.0",
     "@types/chrome": "^0.0.244",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6a2f95b..043d41b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -82,6 +82,9 @@ devDependencies:
   '@interledger/http-signature-utils':
     specifier: ^2.0.0
     version: 2.0.0
+  '@tailwindcss/forms':
+    specifier: ^0.5.7
+    version: 0.5.7(tailwindcss@3.4.0)
   '@testing-library/jest-dom':
     specifier: ^6.1.3
     version: 6.1.5(@types/jest@29.5.11)(jest@29.7.0)
@@ -1201,6 +1204,15 @@ packages:
       '@sinonjs/commons': 3.0.0
     dev: true
 
+  /@tailwindcss/forms@0.5.7(tailwindcss@3.4.0):
+    resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==}
+    peerDependencies:
+      tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
+    dependencies:
+      mini-svg-data-uri: 1.4.4
+      tailwindcss: 3.4.0(ts-node@10.9.2)
+    dev: true
+
   /@testing-library/dom@9.3.3:
     resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==}
     engines: {node: '>=14'}
@@ -5700,6 +5712,11 @@ packages:
     engines: {node: '>=4'}
     dev: true
 
+  /mini-svg-data-uri@1.4.4:
+    resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
+    hasBin: true
+    dev: true
+
   /minimatch@3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
     dependencies:
diff --git a/src/components/radio-group.tsx b/src/components/radio-group.tsx
new file mode 100644
index 0000000..862cc0a
--- /dev/null
+++ b/src/components/radio-group.tsx
@@ -0,0 +1,148 @@
+import { type VariantProps, cva } from 'class-variance-authority'
+import React, { useEffect, useMemo, useState } from 'react'
+
+import { cn } from '@/utils/cn'
+
+export interface RadioProps {
+  checked?: boolean
+  label?: string
+  value: string
+  name: string
+  id?: string
+  disabled?: boolean
+  onChange?: any
+  noSelected?: boolean
+}
+
+export const Radio = ({
+  label,
+  id,
+  name,
+  value,
+  disabled,
+  onChange,
+  checked,
+  noSelected,
+}: RadioProps): JSX.Element => {
+  const inputId = id || `id-${name}-${value}`
+  const divId = `div-${inputId}`
+
+  useEffect(() => {
+    if (checked) document.getElementById(divId)?.focus()
+  }, [checked, divId])
+
+  return (
+    <div
+      id={divId}
+      className="flex items-center"
+      tabIndex={noSelected ? 0 : checked ? 0 : -1}
+      aria-checked={checked}
+      role="radio">
+      <input
+        id={inputId}
+        type="radio"
+        disabled={disabled}
+        value={value}
+        name={name}
+        className="hidden peer"
+        onChange={onChange}
+        checked={checked}
+        aria-checked={checked}
+      />
+
+      <label htmlFor={inputId} className="group flex items-center">
+        <span
+          className={`w-6 h-6 inline-block rounded-full border-2 border-base
+          peer-checked:group-[]:bg-primary
+          peer-checked:group-[]:border-blue-500
+          peer-checked:group-[]:ring-white
+          peer-checked:group-[]:ring-inset
+          peer-checked:group-[]:ring-4`}
+        />
+        {label ? <p className="text-base text-medium leading-6 ms-2">{label}</p> : ''}
+      </label>
+    </div>
+  )
+}
+
+const radioGroupVariants = cva(['flex gap-3'], {
+  variants: {
+    variant: {
+      default: 'flex-col',
+      inline: 'flex-row',
+    },
+    fullWidth: {
+      true: 'w-full',
+    },
+  },
+  defaultVariants: {
+    variant: 'default',
+  },
+})
+
+export interface RadioGroupProps
+  extends VariantProps<typeof radioGroupVariants>,
+    React.InputHTMLAttributes<HTMLInputElement> {
+  disabled?: boolean
+  items: Omit<RadioProps, 'name'>[]
+  name: string
+}
+
+export const RadioGroup = ({
+  items,
+  variant,
+  name,
+  fullWidth,
+  disabled,
+  className,
+}: RadioGroupProps) => {
+  const checkedItem = useMemo(() => items.findIndex(item => item.checked), [items])
+  const [selected, setSelected] = useState(checkedItem)
+
+  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
+    if (event.code === 'ArrowRight' || event.code === 'ArrowDown') {
+      event.preventDefault()
+
+      const nextIndex = (selected >= 0 ? selected + 1 : 1) % items.length
+      setSelected(nextIndex)
+    } else if (event.code === 'ArrowLeft' || event.code === 'ArrowUp') {
+      event.preventDefault()
+
+      const prevIndex = selected > 0 ? selected - 1 : items.length - 1
+      setSelected(prevIndex)
+    }
+  }
+
+  useEffect(() => {
+    const handleKeyPress = (event: KeyboardEvent) => {
+      if (selected === -1 && (event.code === 'Enter' || event.code === 'Space')) {
+        setSelected(0)
+      }
+    }
+
+    document.addEventListener('keypress', handleKeyPress)
+    return () => {
+      document.removeEventListener('keypress', handleKeyPress)
+    }
+  }, [selected])
+
+  return (
+    <div
+      tabIndex={-1}
+      className={cn(radioGroupVariants({ variant, fullWidth }), className)}
+      onKeyDown={handleKeyDown}
+      role="radiogroup">
+      {items.map((item, index) => (
+        <Radio
+          key={`key-${name}-${item.value}`}
+          {...item}
+          name={name}
+          disabled={disabled}
+          checked={selected === index}
+          noSelected={selected === -1 && index === 0}
+          onChange={() => setSelected(index)}
+        />
+      ))}
+    </div>
+  )
+}
diff --git a/src/components/slider.tsx b/src/components/slider.tsx
new file mode 100644
index 0000000..2e8b454
--- /dev/null
+++ b/src/components/slider.tsx
@@ -0,0 +1,57 @@
+import React, { forwardRef } from 'react'
+
+import { cn } from '@/utils/cn'
+
+export interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  errorMessage?: string
+  disabled?: boolean
+  icon?: React.ReactNode
+  min?: number
+  max?: number
+  value?: number
+  defaultValue?: number
+}
+
+export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
+  { errorMessage, defaultValue, value, className, disabled, ...props },
+  ref,
+) {
+  const [innerValue, setInnerValue] = React.useState<number>(value || defaultValue || 0)
+
+  return (
+    <div className="w-100">
+      <input
+        ref={ref}
+        type="range"
+        className={
+          (cn(
+            `[&::-webkit-slider-thumb]:appearance-none
+        [&::-webkit-slider-thumb]:w-5
+        [&::-webkit-slider-thumb]:h-5
+        [&::-webkit-slider-thumb]:bg-switch-base
+        [&::-webkit-slider-thumb]:rounded-full
+        [&::-moz-range-thumb]:appearance-none
+        [&::-moz-range-thumb]:w-5
+        [&::-moz-range-thumb]:h-5
+        [&::-moz-range-thumb]:bg-switch-base
+        [&::-moz-range-thumb]:rounded-full
+        w-full h-1 bg-disabled-strong rounded-lg appearance-none cursor-pointer dark:bg-disabled-strong`,
+            innerValue === 0 &&
+              '[&::-webkit-slider-thumb]:bg-disabled-strong [&::-moz-range-thumb]:bg-disabled-strong',
+          ),
+          className)
+        }
+        disabled={disabled ?? false}
+        aria-disabled={disabled ?? false}
+        aria-invalid={!!errorMessage}
+        aria-describedby={errorMessage}
+        defaultValue={defaultValue}
+        value={innerValue}
+        onChange={e => setInnerValue(Number(e.target.value))}
+        {...props}
+      />
+
+      {errorMessage && <p className="text-error text-sm px-2">{errorMessage}</p>}
+    </div>
+  )
+})
diff --git a/src/manifest/chrome.json b/src/manifest/chrome.json
index 68de6a3..8805a11 100644
--- a/src/manifest/chrome.json
+++ b/src/manifest/chrome.json
@@ -1,33 +1,27 @@
 {
-    "name": "__MSG_appName__",
-    "version": "1.0.1",
-    "manifest_version": 2,
-    "description": "__MSG_appDescription__",
-    "icons": {
-        "34": "assets/icons/icon-34.png",
-        "128": "assets/icons/icon-128.png"
-    },
-    "default_locale": "en",
-    "content_scripts": [
-        {
-            "matches": ["http://*/*", "https://*/*", "<all_urls>"],
-            "js": ["content/content.js"]
-        }
-    ],
-    "background": {
-        "scripts": ["background/background.js"]
-    },
-    "permissions": ["tabs", "storage"],
-    "browser_action": {
-        "default_icon": "assets/icons/icon-34.png",
-        "default_title": "Web Monetization",
-        "default_popup": "popup/index.html"
-    },
-    "web_accessible_resources": [
-        "assets/*",
-        "content/*",
-        "options/*",
-        "popup/*",
-        "background/*"
-    ]
+  "name": "__MSG_appName__",
+  "version": "1.0.1",
+  "manifest_version": 2,
+  "description": "__MSG_appDescription__",
+  "icons": {
+    "34": "assets/icons/icon-34.png",
+    "128": "assets/icons/icon-128.png"
+  },
+  "default_locale": "en",
+  "content_scripts": [
+    {
+      "matches": ["http://*/*", "https://*/*", "<all_urls>"],
+      "js": ["content/content.js"]
+    }
+  ],
+  "background": {
+    "scripts": ["background/background.js"]
+  },
+  "permissions": ["tabs", "storage"],
+  "browser_action": {
+    "default_icon": "assets/icons/icon-34.png",
+    "default_title": "Web Monetization",
+    "default_popup": "popup/index.html"
+  },
+  "web_accessible_resources": ["assets/*", "content/*", "options/*", "popup/*", "background/*"]
 }
diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx
index 504aacd..89d305c 100644
--- a/src/popup/Popup.tsx
+++ b/src/popup/Popup.tsx
@@ -1,7 +1,7 @@
-import React from 'react'
-
 import './Popup.scss'
 
+import React from 'react'
+
 import { RouterProvider } from '@/components/router-provider'
 
 const Popup = () => {
diff --git a/tailwind.config.ts b/tailwind.config.ts
index a04b5c2..f44ecc3 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -41,5 +41,5 @@ module.exports = {
       },
     },
   },
-  plugins: [],
+  plugins: [require('@tailwindcss/forms')],
 } satisfies Config


Test patch:
diff --git a/src/components/__tests__/radio-group.test.tsx b/src/components/__tests__/radio-group.test.tsx
new file mode 100644
index 0000000..f152c11
--- /dev/null
+++ b/src/components/__tests__/radio-group.test.tsx
@@ -0,0 +1,104 @@
+import { fireEvent, render } from '@testing-library/react'
+import React from 'react'
+
+import { Radio, RadioGroup } from '../radio-group'
+
+describe('RadioGroup', () => {
+  const radioItems = [
+    { label: 'Option 1', value: 'option1', checked: true },
+    { label: 'Option 2', value: 'option2' },
+  ]
+
+  it('should have the `flex-row` class when the `inline` variant is passed', () => {
+    const { queryByRole } = render(
+      <RadioGroup variant="inline" items={radioItems} name="radioName" />,
+    )
+
+    expect(queryByRole('radiogroup')).toBeInTheDocument()
+    expect(queryByRole('radiogroup')).toHaveClass('flex-row')
+  })
+
+  it('renders radio group correctly with items', () => {
+    const { getByRole } = render(<RadioGroup items={radioItems} name="radioGroup" />)
+
+    const radioGroup = getByRole('radiogroup')
+    expect(radioGroup).toBeInTheDocument()
+    expect(radioGroup.childNodes.length).toBe(2) // Ensure two radio buttons are rendered
+  })
+
+  it('renders radio group with no element checked by default', () => {
+    const radioItemsNotChecked = [
+      { label: 'Option 1', value: 'option1' },
+      { label: 'Option 2', value: 'option2' },
+    ]
+    const { getByLabelText } = render(<RadioGroup items={radioItemsNotChecked} name="radioGroup" />)
+
+    const firstRadioButton = getByLabelText('Option 1')
+    const secondRadioButton = getByLabelText('Option 2')
+
+    expect(firstRadioButton).not.toBeChecked()
+    expect(secondRadioButton).not.toBeChecked()
+  })
+
+  it('handles keyboard navigation', () => {
+    const { getByLabelText } = render(<RadioGroup items={radioItems} name="radioGroup" />)
+
+    const radioGroup = getByLabelText('Option 1')
+    fireEvent.keyDown(radioGroup, { key: 'ArrowRight', code: 'ArrowRight' })
+    let secondRadioButton = getByLabelText('Option 2')
+    expect(secondRadioButton).toBeChecked()
+
+    fireEvent.keyDown(radioGroup, { key: 'ArrowLeft', code: 'ArrowLeft' })
+    let firstRadioButton = getByLabelText('Option 1')
+    expect(firstRadioButton).toBeChecked()
+
+    fireEvent.keyDown(radioGroup, { key: 'ArrowUp', code: 'ArrowUp' })
+    secondRadioButton = getByLabelText('Option 2')
+    expect(secondRadioButton).toBeChecked()
+
+    fireEvent.keyDown(radioGroup, { key: 'ArrowDown', code: 'ArrowDown' })
+    firstRadioButton = getByLabelText('Option 1')
+    expect(firstRadioButton).toBeChecked()
+  })
+
+  it('changes selection on arrow keys', () => {
+    const { getByLabelText } = render(<RadioGroup items={radioItems} name="radioGroup" />)
+
+    const radioGroup = getByLabelText('Option 1')
+    fireEvent.keyDown(radioGroup, { key: 'ArrowRight', code: 'ArrowRight' })
+    fireEvent.keyDown(radioGroup, { key: 'Enter', code: 'Enter' })
+    const secondRadioButton = getByLabelText('Option 2')
+    expect(secondRadioButton).toBeChecked()
+  })
+
+  it('changes selection on clicking radio buttons', () => {
+    const { getByLabelText } = render(<RadioGroup items={radioItems} name="radioGroup" />)
+
+    const secondRadioButton = getByLabelText('Option 2')
+    fireEvent.click(secondRadioButton)
+    expect(secondRadioButton).toBeChecked()
+  })
+})
+
+describe('Radio', () => {
+  it('renders radio button correctly with label', () => {
+    const { getByLabelText } = render(<Radio label="Option 1" value="option1" name="radioGroup" />)
+
+    const radioButton = getByLabelText('Option 1')
+    expect(radioButton).toBeInTheDocument()
+    expect(radioButton).toHaveAttribute('type', 'radio')
+    expect(radioButton).not.toBeChecked()
+
+    fireEvent.click(radioButton)
+    expect(radioButton).toBeChecked()
+  })
+
+  it('renders disabled radio button', () => {
+    const { getByLabelText } = render(
+      <Radio label="Option 1" value="option1" name="radioGroup" disabled />,
+    )
+
+    const radioButton = getByLabelText('Option 1')
+    expect(radioButton).toBeDisabled()
+  })
+})