diff --git a/.eslintrc.json b/.eslintrc.json
index ca9b8c95..01f96c60 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 f24d2909..7cb1f3b6 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 6a2f95b4..043d41b6 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/__tests__/radio-group.test.tsx b/src/components/__tests__/radio-group.test.tsx
new file mode 100644
index 00000000..f152c112
--- /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(
+ ,
+ )
+
+ expect(queryByRole('radiogroup')).toBeInTheDocument()
+ expect(queryByRole('radiogroup')).toHaveClass('flex-row')
+ })
+
+ it('renders radio group correctly with items', () => {
+ const { getByRole } = render()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ const secondRadioButton = getByLabelText('Option 2')
+ fireEvent.click(secondRadioButton)
+ expect(secondRadioButton).toBeChecked()
+ })
+})
+
+describe('Radio', () => {
+ it('renders radio button correctly with label', () => {
+ const { getByLabelText } = render()
+
+ 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(
+ ,
+ )
+
+ const radioButton = getByLabelText('Option 1')
+ expect(radioButton).toBeDisabled()
+ })
+})
diff --git a/src/components/radio-group.tsx b/src/components/radio-group.tsx
new file mode 100644
index 00000000..862cc0a4
--- /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 (
+
+
+
+
+
+ )
+}
+
+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,
+ React.InputHTMLAttributes {
+ disabled?: boolean
+ items: Omit[]
+ 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) => {
+ 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 (
+
+ {items.map((item, index) => (
+ setSelected(index)}
+ />
+ ))}
+
+ )
+}
diff --git a/src/manifest/chrome.json b/src/manifest/chrome.json
index 68de6a36..8805a11e 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://*/*", ""],
- "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://*/*", ""],
+ "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 504aacdc..89d305cf 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 a04b5c2e..f44ecc34 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -41,5 +41,5 @@ module.exports = {
},
},
},
- plugins: [],
+ plugins: [require('@tailwindcss/forms')],
} satisfies Config