diff --git a/apps/www/src/components/typetable/typetable.module.css b/apps/www/src/components/typetable/typetable.module.css index 305aea381..9d422fb25 100644 --- a/apps/www/src/components/typetable/typetable.module.css +++ b/apps/www/src/components/typetable/typetable.module.css @@ -99,3 +99,8 @@ flex-wrap: wrap; gap: var(--rs-space-1); } +.required { + color: var(--rs-color-foreground-danger-primary); + position: relative; + top: -4px; +} diff --git a/apps/www/src/components/typetable/typetable.tsx b/apps/www/src/components/typetable/typetable.tsx index 90ec1d63d..2f15a29ff 100644 --- a/apps/www/src/components/typetable/typetable.tsx +++ b/apps/www/src/components/typetable/typetable.tsx @@ -45,7 +45,10 @@ export interface TypeNode { export function TypeTable({ type, className -}: { type: Record; className?: string }) { +}: { + type: Record; + className?: string; +}) { const entries = Object.entries(type); return ( @@ -87,7 +90,7 @@ function Item({ className={deprecated ? styles.propNameDeprecated : styles.propName} > {name} - {!required && '?'} + {required ? * : ''} {typeDescriptionLink ? ( @@ -130,7 +133,10 @@ function Item({ language='tsx' className={cx(styles.fieldCode, styles.fieldValue)} > - {String(typeDescription ?? type)} + {String(type) + + (!required && !String(type).includes('undefined') + ? ' | undefined' + : '')} diff --git a/apps/www/src/content/docs/components/radio/demo.ts b/apps/www/src/content/docs/components/radio/demo.ts index b0a3ca802..f52931f60 100644 --- a/apps/www/src/content/docs/components/radio/demo.ts +++ b/apps/www/src/content/docs/components/radio/demo.ts @@ -3,22 +3,22 @@ export const preview = { type: 'code', code: ` - - + + - - - - - - - - - - + + + + + + + + + + - ` + ` }; export const stateDemo = { @@ -27,22 +27,22 @@ export const stateDemo = { { name: 'Default', code: ` - + - + -` +` }, { name: 'Disabled', code: ` - + - + -` +` } ] }; @@ -50,22 +50,20 @@ export const stateDemo = { export const labelDemo = { type: 'code', code: ` - - + - + - + - + - - ` + ` }; export const formDemo = { @@ -77,18 +75,18 @@ export const formDemo = { alert(JSON.stringify(Object.fromEntries(formData))); }}> - + - + - + - + ` diff --git a/apps/www/src/content/docs/components/radio/index.mdx b/apps/www/src/content/docs/components/radio/index.mdx index 0123ac444..2433ca45c 100644 --- a/apps/www/src/content/docs/components/radio/index.mdx +++ b/apps/www/src/content/docs/components/radio/index.mdx @@ -16,13 +16,14 @@ import { Radio } from "@raystack/apsara"; ## Radio Props -### Radio Props +### Radio.Group Props + + - +### Radio Props -### Radio.Item Props + - ## Examples diff --git a/apps/www/src/content/docs/components/radio/props.ts b/apps/www/src/content/docs/components/radio/props.ts index b2a311d64..b490a44e9 100644 --- a/apps/www/src/content/docs/components/radio/props.ts +++ b/apps/www/src/content/docs/components/radio/props.ts @@ -1,12 +1,12 @@ -export interface RadioRootProps { +export interface RadioGroupProps { /** The value of the radio item that should be checked by default. */ - defaultValue?: string; + defaultValue?: any; /** The controlled value of the radio item that is checked. */ - value?: string; + value?: any; /** Event handler called when the value changes. */ - onValueChange?: (value: string) => void; + onValueChange?: (value: any, event: Event) => void; /** When true, prevents user interaction with the radio group. */ disabled?: boolean; @@ -14,29 +14,43 @@ export interface RadioRootProps { /** The name of the radio group when submitted as a form field. */ name?: string; - /** When true, indicates that a value must be selected before the form can be submitted. */ - required?: boolean; - - /** The orientation of the radio group. */ - orientation?: 'horizontal' | 'vertical'; - - /** The reading direction of the radio group. */ - dir?: 'ltr' | 'rtl'; - - /** A label for the radio group that is announced by screen readers. */ - ariaLabel?: string; + /** Additional CSS class name. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | ((props: React.HTMLAttributes) => React.ReactElement); } -export interface RadioItemProps { +export interface RadioProps { /** The unique value of the radio item. */ - value: string; + value: any; /** When true, prevents user interaction with this radio item. */ disabled?: boolean; - /** When true, indicates that this radio item must be checked. */ - required?: boolean; - /** The unique identifier for the radio item. */ id?: string; + + /** Additional CSS class name. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | (( + props: React.HTMLAttributes, + state: { checked: boolean } + ) => React.ReactElement); } diff --git a/packages/raystack/components/radio/__tests__/radio.test.tsx b/packages/raystack/components/radio/__tests__/radio.test.tsx index 00cd7ff89..8845b06ca 100644 --- a/packages/raystack/components/radio/__tests__/radio.test.tsx +++ b/packages/raystack/components/radio/__tests__/radio.test.tsx @@ -1,15 +1,15 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { Radio, RadioItem } from '../radio'; +import { Radio } from '../radio'; describe('Radio', () => { describe('Basic Rendering', () => { it('renders radio group', () => { render( - - - + + + ); const radioGroup = screen.getByRole('radiogroup'); expect(radioGroup).toBeInTheDocument(); @@ -17,11 +17,11 @@ describe('Radio', () => { it('renders multiple radio items', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); @@ -32,10 +32,10 @@ describe('Radio', () => { describe('Selection Behavior', () => { it('allows single selection', () => { render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); @@ -51,11 +51,11 @@ describe('Radio', () => { it('works with defaultValue', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); @@ -66,10 +66,10 @@ describe('Radio', () => { it('works as controlled component', () => { const { rerender } = render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); @@ -77,10 +77,10 @@ describe('Radio', () => { expect(radio2).not.toBeChecked(); rerender( - - - - + + + + ); expect(radio1).not.toBeChecked(); @@ -90,56 +90,56 @@ describe('Radio', () => { it('calls onValueChange when selection changes', () => { const handleChange = vi.fn(); render( - - - - + + + + ); const radio2 = screen.getAllByRole('radio')[1]; fireEvent.click(radio2); - expect(handleChange).toHaveBeenCalledWith('option2'); + expect(handleChange).toHaveBeenCalledWith('option2', expect.anything()); }); }); describe('Disabled State', () => { it('disables entire radio group', () => { render( - - - - + + + + ); const radios = screen.getAllByRole('radio'); radios.forEach(radio => { - expect(radio).toBeDisabled(); + expect(radio).toHaveAttribute('data-disabled'); }); }); it('disables individual radio items', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); - expect(radios[0]).not.toBeDisabled(); - expect(radios[1]).toBeDisabled(); - expect(radios[2]).not.toBeDisabled(); + expect(radios[0]).not.toHaveAttribute('data-disabled'); + expect(radios[1]).toHaveAttribute('data-disabled'); + expect(radios[2]).not.toHaveAttribute('data-disabled'); }); it('does not allow selection of disabled items', () => { const handleChange = vi.fn(); render( - - - - + + + + ); const disabledRadio = screen.getAllByRole('radio')[1]; @@ -154,17 +154,17 @@ describe('Radio', () => { it('supports arrow key navigation', async () => { const user = userEvent.setup(); render( - - - - - + + + + + ); const [radio1, radio2, radio3] = screen.getAllByRole('radio'); // Focus first radio - await radio1.focus(); + radio1.focus(); expect(document.activeElement).toBe(radio1); // Arrow down should move to next @@ -183,17 +183,17 @@ describe('Radio', () => { it('wraps around when navigating past boundaries', async () => { const user = userEvent.setup(); render( - - - - - + + + + + ); const [radio1, , radio3] = screen.getAllByRole('radio'); // Focus last radio - await radio3.focus(); + radio3.focus(); // Arrow down from last should wrap to first await user.keyboard('{ArrowDown}'); @@ -208,9 +208,9 @@ describe('Radio', () => { describe('Accessibility', () => { it('has correct ARIA attributes on group', () => { render( - - - + + + ); const radioGroup = screen.getByRole('radiogroup'); @@ -219,10 +219,10 @@ describe('Radio', () => { it('has correct ARIA attributes on items', () => { render( - - - - + + + + ); const radio1 = screen.getByLabelText('First option'); @@ -231,76 +231,31 @@ describe('Radio', () => { expect(radio1).toHaveAttribute('aria-checked', 'true'); expect(radio2).toHaveAttribute('aria-checked', 'false'); }); - - it('supports required attribute', () => { - render( - - - - ); - - const radioGroup = screen.getByRole('radiogroup'); - expect(radioGroup).toHaveAttribute('aria-required', 'true'); - }); - }); - - describe('Form Integration', () => { - it('works with form name attribute', () => { - const { container } = render( -
- - - - -
- ); - - const radios = container.querySelectorAll('input[type="radio"]'); - radios.forEach(radio => { - expect(radio).toHaveAttribute('name', 'preference'); - }); - }); - - it('respects form disabled state', () => { - render( -
- - - - -
- ); - - const radios = screen.getAllByRole('radio'); - radios.forEach(radio => { - expect(radio).toBeDisabled(); - }); - }); }); describe('Data Attributes', () => { - it('has data-state attribute on items', () => { + it('has data-checked attribute on selected items', () => { render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); - expect(radio1).toHaveAttribute('data-state', 'checked'); - expect(radio2).toHaveAttribute('data-state', 'unchecked'); + expect(radio1).toHaveAttribute('data-checked'); + expect(radio2).toHaveAttribute('data-unchecked'); }); it('has data-disabled attribute when disabled', () => { render( - - - + + + ); const radio = screen.getByRole('radio'); - expect(radio).toHaveAttribute('data-disabled', ''); + expect(radio).toHaveAttribute('data-disabled'); }); }); }); diff --git a/packages/raystack/components/radio/index.ts b/packages/raystack/components/radio/index.ts index c8a266a51..d9dae10e1 100644 --- a/packages/raystack/components/radio/index.ts +++ b/packages/raystack/components/radio/index.ts @@ -1 +1 @@ -export { Radio } from "./radio"; +export { Radio } from './radio'; diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 74285b704..a30d81675 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -24,12 +24,12 @@ background: var(--rs-color-background-base-primary-hover); } -.radioitem[data-state="checked"] { +.radioitem[data-checked] { border: 1px solid var(--rs-color-background-accent-emphasis); background: var(--rs-color-background-accent-emphasis); } -.radioitem[data-state="checked"]:hover { +.radioitem[data-checked]:hover { border-color: var(--rs-color-background-accent-emphasis-hover); background: var(--rs-color-background-accent-emphasis-hover); } @@ -41,8 +41,8 @@ cursor: not-allowed; } -.radioitem[data-disabled][data-state="checked"], -.radioitem[data-disabled][data-state="checked"]:hover { +.radioitem[data-disabled][data-checked], +.radioitem[data-disabled][data-checked]:hover { background: var(--rs-color-background-accent-primary); border-color: var(--rs-color-background-accent-primary); } @@ -57,7 +57,7 @@ } .indicator::after { - content: ''; + content: ""; display: block; width: var(--rs-radius-3); height: var(--rs-radius-3); @@ -67,4 +67,4 @@ .radioitem[data-disabled] .indicator::after { background: var(--rs-color-foreground-base-emphasis); -} \ No newline at end of file +} diff --git a/packages/raystack/components/radio/radio.tsx b/packages/raystack/components/radio/radio.tsx index c79e2d1ea..ff1d0ac93 100644 --- a/packages/raystack/components/radio/radio.tsx +++ b/packages/raystack/components/radio/radio.tsx @@ -1,53 +1,36 @@ -import { VariantProps, cva } from 'class-variance-authority'; -import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Radio as RadioPrimitive } from '@base-ui/react/radio'; +import { RadioGroup as RadioGroupPrimitive } from '@base-ui/react/radio-group'; +import { cx } from 'class-variance-authority'; +import { forwardRef } from 'react'; import styles from './radio.module.css'; -const RadioRoot = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -const radioItem = cva(styles.radioitem); - -export interface RadioItemProps - extends ComponentPropsWithoutRef {} - -export const RadioItem = forwardRef< - ElementRef, - RadioItemProps ->(({ className, ...props }, forwardedRef) => ( - - - -)); - -const indicator = cva(styles.indicator); -export interface thumbProps - extends ComponentPropsWithoutRef, - VariantProps {} - -const Indicator = forwardRef< - ElementRef, - thumbProps ->(({ className, ...props }, ref) => ( - -)); - -Indicator.displayName = RadioGroupPrimitive.Indicator.displayName; - -export const Radio = Object.assign(RadioRoot, { - Indicator: Indicator, - Item: RadioItem +const RadioGroup = forwardRef( + ({ className, ...props }, ref) => ( + + ) +); + +RadioGroup.displayName = 'Radio.Group'; + +const RadioItem = forwardRef( + ({ className, ...props }, forwardedRef) => ( + + + + ) +); + +RadioItem.displayName = 'Radio'; + +export const Radio = Object.assign(RadioItem, { + Group: RadioGroup });