diff --git a/apps/www/src/content/docs/components/radio/demo.ts b/apps/www/src/content/docs/components/radio/demo.ts index f52931f60..d31c3be19 100644 --- a/apps/www/src/content/docs/components/radio/demo.ts +++ b/apps/www/src/content/docs/components/radio/demo.ts @@ -3,22 +3,20 @@ export const preview = { type: 'code', code: ` - - - - - - - - - - - - - - - - ` + + + + + + + + + + + + + +` }; export const stateDemo = { @@ -47,44 +45,118 @@ export const stateDemo = { ] }; +export const sizeDemo = { + type: 'code', + tabs: [ + { + name: 'Large (default)', + code: ` + + + + + + + + + +` + }, + { + name: 'Small', + code: ` + + + + + + + + + +` + } + ] +}; + +export const orientationDemo = { + type: 'code', + tabs: [ + { + name: 'Vertical (default)', + code: ` + + + + + + + + + + + + + +` + }, + { + name: 'Horizontal', + code: ` + + + + + + + + + + + + + +` + } + ] +}; + export const labelDemo = { type: 'code', code: ` - - - - - - - - - - - - - - ` + + + + + + + + + + + + + +` }; export const formDemo = { type: 'code', code: ` -
{ + { e.preventDefault(); const formData = new FormData(e.target); 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 4dac97709..b246a35d4 100644 --- a/apps/www/src/content/docs/components/radio/index.mdx +++ b/apps/www/src/content/docs/components/radio/index.mdx @@ -4,7 +4,7 @@ description: A radio group component for selecting a single option from a list o source: packages/raystack/components/radio --- -import { preview, stateDemo, labelDemo, formDemo } from "./demo.ts"; +import { preview, stateDemo, sizeDemo, orientationDemo, labelDemo, formDemo } from "./demo.ts"; @@ -43,6 +43,18 @@ Radio buttons support different states to indicate interactivity and selection. +### Size Variants + +The Radio component comes in two sizes: large (default) and small. + + + +### Orientation + +The Radio.Group supports vertical (default) and horizontal orientation to control the layout direction of its items. + + + ### With Labels Radio buttons should always be accompanied by labels for accessibility and usability. diff --git a/apps/www/src/content/docs/components/radio/props.ts b/apps/www/src/content/docs/components/radio/props.ts index b490a44e9..566a9bd0a 100644 --- a/apps/www/src/content/docs/components/radio/props.ts +++ b/apps/www/src/content/docs/components/radio/props.ts @@ -11,6 +11,12 @@ export interface RadioGroupProps { /** When true, prevents user interaction with the radio group. */ disabled?: boolean; + /** + * The layout orientation of the radio group. + * @default "vertical" + */ + orientation?: 'vertical' | 'horizontal'; + /** The name of the radio group when submitted as a form field. */ name?: string; @@ -35,6 +41,12 @@ export interface RadioProps { /** When true, prevents user interaction with this radio item. */ disabled?: boolean; + /** + * The size of the radio button. + * @default "large" + */ + size?: 'large' | 'small'; + /** The unique identifier for the radio item. */ id?: string; diff --git a/packages/raystack/components/radio/__tests__/radio.test.tsx b/packages/raystack/components/radio/__tests__/radio.test.tsx index 8845b06ca..5eaeb7139 100644 --- a/packages/raystack/components/radio/__tests__/radio.test.tsx +++ b/packages/raystack/components/radio/__tests__/radio.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Radio } from '../radio'; +import styles from '../radio.module.css'; describe('Radio', () => { describe('Basic Rendering', () => { @@ -258,4 +259,75 @@ describe('Radio', () => { expect(radio).toHaveAttribute('data-disabled'); }); }); + + describe('Sizes', () => { + const sizes = ['small', 'large'] as const; + sizes.forEach(size => { + it(`renders ${size} size`, () => { + render( + + + + ); + const radio = screen.getByRole('radio'); + expect(radio).toHaveClass(styles[size]); + }); + }); + + it('renders large size by default', () => { + render( + + + + ); + const radio = screen.getByRole('radio'); + expect(radio).toHaveClass(styles.large); + }); + + it('applies radioitem base class with size variant', () => { + render( + + + + ); + const radio = screen.getByRole('radio'); + expect(radio).toHaveClass(styles.radioitem); + expect(radio).toHaveClass(styles.small); + }); + }); + + describe('Orientation', () => { + it('applies vertical layout by default', () => { + render( + + + + ); + const group = screen.getByRole('radiogroup'); + expect(group).toHaveClass(styles.group); + expect(group).not.toHaveClass(styles['group-horizontal']); + }); + + it('applies vertical layout when orientation is vertical', () => { + render( + + + + ); + const group = screen.getByRole('radiogroup'); + expect(group).toHaveClass(styles.group); + expect(group).not.toHaveClass(styles['group-horizontal']); + }); + + it('applies horizontal layout when orientation is horizontal', () => { + render( + + + + ); + const group = screen.getByRole('radiogroup'); + expect(group).toHaveClass(styles.group); + expect(group).toHaveClass(styles['group-horizontal']); + }); + }); }); diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 2f80722f3..27b3d9b72 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -1,14 +1,17 @@ -.radio { +/* Group layout — uses flex to arrange radio items */ +.group { display: flex; + flex-direction: column; gap: var(--rs-space-3); } +.group-horizontal { + flex-direction: row; +} + +/* Radio button — uses inline-flex to center the indicator dot */ .radioitem { all: unset; - width: var(--rs-space-5); - height: var(--rs-space-5); - min-width: var(--rs-space-5); - min-height: var(--rs-space-5); border-radius: var(--rs-radius-full); border: 1px solid var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); @@ -19,6 +22,20 @@ box-sizing: border-box; } +.large { + width: var(--rs-space-5); + height: var(--rs-space-5); + min-width: var(--rs-space-5); + min-height: var(--rs-space-5); +} + +.small { + width: var(--rs-space-4); + height: var(--rs-space-4); + min-width: var(--rs-space-4); + min-height: var(--rs-space-4); +} + .radioitem:hover { border-color: var(--rs-color-border-base-tertiary-hover); background: var(--rs-color-background-base-primary-hover); @@ -58,12 +75,18 @@ .indicator::after { content: ""; display: block; - width: var(--rs-radius-3); - height: var(--rs-radius-3); + width: var(--rs-space-2); + height: var(--rs-space-2); border-radius: var(--rs-radius-full); background: var(--rs-color-foreground-base-emphasis); } +/* Scale indicator dot for small size */ +.small .indicator::after { + width: var(--rs-space-1); + height: var(--rs-space-1); +} + .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 1073b3868..0169f6efb 100644 --- a/packages/raystack/components/radio/radio.tsx +++ b/packages/raystack/components/radio/radio.tsx @@ -1,21 +1,42 @@ 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 { cva, cx, type VariantProps } from 'class-variance-authority'; import { useFieldContext } from '../field'; import styles from './radio.module.css'; +const radioVariants = cva(styles.radioitem, { + variants: { + size: { + small: styles.small, + large: styles.large + } + }, + defaultVariants: { + size: 'large' + } +}); + +interface RadioGroupProps extends RadioGroupPrimitive.Props { + orientation?: 'vertical' | 'horizontal'; +} + function RadioGroup({ className, + orientation = 'vertical', required, ...props -}: RadioGroupPrimitive.Props) { +}: RadioGroupProps) { const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; return ( @@ -24,9 +45,16 @@ function RadioGroup({ RadioGroup.displayName = 'Radio.Group'; -function RadioItem({ className, ...props }: RadioPrimitive.Root.Props) { +interface RadioItemProps + extends RadioPrimitive.Root.Props, + VariantProps {} + +function RadioItem({ className, size, ...props }: RadioItemProps) { return ( - + );