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