diff --git a/backlog/archive/tasks/task-007 - Create-design-tokens-for-Radio-component.md b/backlog/archive/tasks/task-007 - Create-design-tokens-for-Radio-component.md new file mode 100644 index 0000000..3949513 --- /dev/null +++ b/backlog/archive/tasks/task-007 - Create-design-tokens-for-Radio-component.md @@ -0,0 +1,25 @@ +--- +id: task-007 +title: Create design tokens for Radio component +status: Done +assignee: + - '@agent-designer' +created_date: '2025-10-09 18:27' +updated_date: '2025-10-09 18:34' +labels: + - component + - tokens + - radio +dependencies: [] +--- + +## Description + + +Design and implement design tokens for the Radio component following the 4-tier token architecture. The Radio component consists of three parts: Radio Group, Radio button, and Radio button Indicator. Tokens must support 'default' and 'error' variants with one size. This task focuses on defining the design system tokens that will be consumed by the component implementation. + + +## Acceptance Criteria + +- [ ] #1 Design tokens created for Radio Group (spacing, layout),Design tokens created for Radio button (colors, borders, spacing, states),Design tokens created for Radio button Indicator (size, colors, positioning),Both 'default' and 'error' variants are supported,Tokens follow the 4-tier architecture (primitives → semantic → component → variant),Token files are properly structured in the tokens directory,Style-dictionary build generates expected CSS variables + diff --git a/backlog/archive/tasks/task-008 - Implement-Radio-component-with-Base-UI-integration.md b/backlog/archive/tasks/task-008 - Implement-Radio-component-with-Base-UI-integration.md new file mode 100644 index 0000000..ad45b60 --- /dev/null +++ b/backlog/archive/tasks/task-008 - Implement-Radio-component-with-Base-UI-integration.md @@ -0,0 +1,25 @@ +--- +id: task-008 +title: Implement Radio component with Base UI integration +status: Done +assignee: + - '@agent-developer' +created_date: '2025-10-09 18:27' +updated_date: '2025-10-09 18:41' +labels: + - component + - implementation + - radio +dependencies: [] +--- + +## Description + + +Implement the Radio component using Base UI as the foundation. The component consists of three parts: Radio Group (to group radio buttons), Radio button (the interactive element), and Radio button Indicator (visual feedback element). The implementation must integrate with the design tokens created in the previous task and use Vanilla-Extract for styling with recipes for prop-to-style mapping. Support label association with Radio button for accessibility. + + +## Acceptance Criteria + +- [ ] #1 Radio Group component implemented with Base UI integration,Radio button component implemented with Base UI Radio as foundation,Radio button Indicator component implemented,Label association properly configured with Radio button,Vanilla-Extract styling implemented using recipes for prop-to-style mapping,Design tokens from style-dictionary properly integrated,Both 'default' and 'error' variants functional,Component is properly typed with TypeScript,Accessibility features implemented (ARIA attributes, keyboard navigation),Component follows existing codebase patterns,Generated styles from design tokens are properly applied + diff --git a/backlog/archive/tasks/task-009 - Create-Storybook-story-for-Radio-component.md b/backlog/archive/tasks/task-009 - Create-Storybook-story-for-Radio-component.md new file mode 100644 index 0000000..9a58631 --- /dev/null +++ b/backlog/archive/tasks/task-009 - Create-Storybook-story-for-Radio-component.md @@ -0,0 +1,30 @@ +--- +id: task-009 +title: Create Storybook story for Radio component +status: Done +assignee: [] +created_date: '2025-10-09 18:27' +updated_date: '2025-10-09 18:42' +labels: + - storybook + - documentation + - radio +dependencies: [] +--- + +## Description + + +Create a comprehensive Storybook story that showcases the Radio component and all its parts (Radio Group, Radio button, Radio button Indicator). The story should demonstrate both 'default' and 'error' variants, label association, different grouping scenarios, and interactive states. This serves as both documentation and a testing playground for the component. + + +## Acceptance Criteria + +- [ ] #1 Single comprehensive Storybook story created for Radio component,Story showcases Radio Group with multiple Radio buttons,Both 'default' and 'error' variants are demonstrated,Label association examples included,Interactive states are visible (hover, focus, checked, disabled),Different grouping scenarios shown (vertical, horizontal if applicable),Story includes controls for testing variants and states,Component documentation is clear and helpful,Story file follows existing Storybook patterns in codebase + + +## Implementation Notes + + +Task completed as part of Task 008. Radio.stories.tsx was created with 6 comprehensive stories covering all acceptance criteria: Default, Variants (default & error), Orientation (vertical & horizontal), States (unchecked, checked, disabled), Controlled example, and FormIntegration example. All stories demonstrate proper label association, interactive states, and follow existing Storybook patterns. + diff --git a/src/components/Radio/Radio.css.ts b/src/components/Radio/Radio.css.ts new file mode 100644 index 0000000..516bf4f --- /dev/null +++ b/src/components/Radio/Radio.css.ts @@ -0,0 +1,117 @@ +import { recipe } from '@vanilla-extract/recipes'; + +import { + radioBaseStyles, + radioGroupBaseStyles, + radioIndicatorBaseStyles, + radioIndicatorVariants, + radioVariants, +} from '../../tokens/generated/components/radio.generated.css'; + +/** + * Radio Root Recipe + * Composed from radio tokens + */ +export const radioRoot = recipe({ + base: { + // CSS reset + all: 'unset', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + userSelect: 'none', + boxSizing: 'border-box', + flexShrink: 0, + position: 'relative', + + // From radio tokens + width: radioBaseStyles.size, + height: radioBaseStyles.size, + borderRadius: radioBaseStyles.borderRadius, + borderWidth: radioBaseStyles.borderWidth, + borderStyle: 'solid', + + // Direct pseudo-selectors in base block + ':disabled': { + cursor: 'not-allowed', + }, + + ':focus-visible': { + outline: '2px solid', + outlineOffset: '2px', + }, + }, + + variants: radioVariants, + + defaultVariants: { + variant: 'default', + }, +}); + +/** + * RadioGroup Recipe + * Composed from radio-group tokens + */ +export const radioGroup = recipe({ + base: { + // CSS reset + all: 'unset', + display: 'flex', + boxSizing: 'border-box', + + // From radio-group tokens + gap: radioGroupBaseStyles.gap, + }, + + variants: { + orientation: { + vertical: { + flexDirection: 'column' as const, + }, + horizontal: { + flexDirection: 'row' as const, + }, + }, + }, + + defaultVariants: { + orientation: 'vertical', + }, +}); + +/** + * RadioIndicator Recipe + * Composed from radio-indicator tokens + */ +export const radioIndicator = recipe({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: radioIndicatorBaseStyles.width, + height: radioIndicatorBaseStyles.height, + borderRadius: radioIndicatorBaseStyles.borderRadius, + pointerEvents: 'none', + + // Hide indicator when not checked + opacity: 0, + transform: 'scale(0)', + transition: 'all 0.15s ease', + + // Show indicator when checked + selectors: { + '[data-checked] &': { + opacity: 1, + transform: 'scale(1)', + }, + }, + }, + + variants: radioIndicatorVariants, + + defaultVariants: { + variant: 'default', + }, +}); diff --git a/src/components/Radio/Radio.stories.tsx b/src/components/Radio/Radio.stories.tsx new file mode 100644 index 0000000..865b9c5 --- /dev/null +++ b/src/components/Radio/Radio.stories.tsx @@ -0,0 +1,360 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Radio, RadioGroup, RadioIndicator } from './Radio'; + +const meta: Meta = { + title: 'Components/Radio', + component: Radio, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Default Radio + * + * Basic radio button with default variant. + */ +export const Default: Story = { + render: () => ( + + + + + + ), +}; + +/** + * Variants + * + * Demonstrates all available variants (default and error). + */ +export const Variants: Story = { + render: () => ( +
+
+

Default Variant

+ + + + +
+ +
+

Error Variant

+ + + + +
+
+ ), +}; + +/** + * Orientation + * + * Demonstrates vertical and horizontal orientation for RadioGroup. + */ +export const Orientation: Story = { + render: () => ( +
+
+

Vertical Orientation (Default)

+ + + + + +
+ +
+

Horizontal Orientation

+ + + + + +
+
+ ), +}; + +/** + * States + * + * Demonstrates different states: default, selected, and disabled. + */ +export const States: Story = { + render: () => ( +
+
+

Default State

+ + + +
+ +
+

Selected State

+ + + +
+ +
+

Disabled State

+ + + + +
+ +
+

Disabled Selected State

+ + + +
+
+ ), +}; + +/** + * Controlled + * + * Demonstrates controlled radio group with external state management. + */ +export const Controlled: Story = { + render: function ControlledRadio() { + const [value, setValue] = React.useState('option2'); + + return ( +
+
+ Selected Value: {String(value)} +
+ setValue(newValue)}> + + + + +
+ ); + }, +}; + +/** + * Form Integration + * + * Demonstrates radio group usage in a form context. + */ +export const FormIntegration: Story = { + render: () => ( +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + alert(`Selected shipping: ${formData.get('shipping')}`); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '16px' }} + > +
+ Shipping Method + + + + + +
+ +
+ ), +}; + +// Add React import for Controlled story +import * as React from 'react'; diff --git a/src/components/Radio/Radio.tsx b/src/components/Radio/Radio.tsx new file mode 100644 index 0000000..927acbf --- /dev/null +++ b/src/components/Radio/Radio.tsx @@ -0,0 +1,109 @@ +import { Radio as BaseRadio } from '@base-ui-components/react/radio'; +import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group'; +import { forwardRef } from 'react'; + +import * as styles from './Radio.css'; + +import type { RecipeVariants } from '@vanilla-extract/recipes'; + +// Extract variant types from recipes +type RadioVariants = NonNullable>; + +/** + * RadioGroup Props Interface + * + * Container for grouping radio buttons together. + * NO className or style props - use predefined variants only. + */ +export interface RadioGroupProps { + name?: string; + value?: unknown; + defaultValue?: unknown; + onValueChange?: ( + value: unknown, + eventDetails: { + reason: 'none'; + event: Event; + cancel: () => void; + allowPropagation: () => void; + isCanceled: boolean; + isPropagationAllowed: boolean; + } + ) => void; + disabled?: boolean; + children: React.ReactNode; + orientation?: 'vertical' | 'horizontal'; +} + +/** + * Radio Props Interface + * + * Individual radio button component. + * NO className or style props - use predefined variants only. + */ +export interface RadioProps extends RadioVariants { + value: string; + disabled?: boolean; + inputRef?: React.Ref; + children?: React.ReactNode; +} + +/** + * RadioIndicator Props Interface + * + * Visual indicator for the selected radio state. + * NO className or style props - use predefined variants only. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface RadioIndicatorProps extends RadioVariants { + // No additional props - inherits variant from context +} + +/** + * RadioGroup Component + * + * Groups radio buttons together with shared state management. + * Uses Base UI RadioGroup for accessibility and keyboard navigation. + * No customization allowed - use as is with predefined variants. + */ +export const RadioGroup = forwardRef( + ({ orientation, children, ...props }, ref) => ( + + {children} + + ) +); + +RadioGroup.displayName = 'RadioGroup'; + +/** + * Radio Component + * + * Accessible radio button component with theme system integration. + * Uses Base UI Radio for accessibility and keyboard navigation. + * No customization allowed - use as is with predefined variants. + */ +export const Radio = forwardRef( + ({ variant, children, ...props }, ref) => ( + + {children} + + ) +); + +Radio.displayName = 'Radio'; + +/** + * RadioIndicator Component + * + * Visual indicator that displays when a radio button is selected. + * Automatically shows/hides based on radio state. + * No customization allowed - use as is with predefined variants. + */ +export const RadioIndicator = forwardRef( + ({ variant }, ref) => ( + + ) +); + +RadioIndicator.displayName = 'RadioIndicator'; diff --git a/src/components/Radio/index.ts b/src/components/Radio/index.ts new file mode 100644 index 0000000..7fa9291 --- /dev/null +++ b/src/components/Radio/index.ts @@ -0,0 +1,2 @@ +export { Radio, RadioGroup, RadioIndicator } from './Radio'; +export type { RadioProps, RadioGroupProps, RadioIndicatorProps } from './Radio'; diff --git a/src/components/index.ts b/src/components/index.ts index 533d6ab..c858138 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,9 @@ export type { CheckboxProps } from './Checkbox'; export { Input } from './Input'; export type { InputProps } from './Input'; +export { Radio, RadioGroup, RadioIndicator } from './Radio'; +export type { RadioProps, RadioGroupProps, RadioIndicatorProps } from './Radio'; + export { Toast, ToastProvider, diff --git a/src/tokens/generated/components/radio.generated.css.ts b/src/tokens/generated/components/radio.generated.css.ts new file mode 100644 index 0000000..9f47269 --- /dev/null +++ b/src/tokens/generated/components/radio.generated.css.ts @@ -0,0 +1,118 @@ +/** + * Radio Component Styles + * Auto-generated from design tokens - DO NOT EDIT + * + * This file contains styles for: + * - radio + * - radio-group + * - radio-indicator + */ +import { tokens } from '../index'; + +export const radioBaseStyles = { + size: tokens.sizing.md, + borderWidth: tokens['border-width'].default, + borderRadius: tokens['border-radius'].full, +}; + +export const radioVariants = { + variant: { + error: { + borderColor: tokens.interactive.danger.default.background, + backgroundColor: tokens.background.surface, + selectors: { + '&:hover:not(:disabled)': { + borderColor: tokens.interactive.danger.hover.background, + backgroundColor: tokens.background.surface, + }, + '&:active:not(:disabled)': { + borderColor: tokens.interactive.danger.active.background, + backgroundColor: tokens.background.surface, + }, + '&:focus': { + borderColor: tokens.interactive.danger.default.background, + }, + '&:disabled': { + borderColor: tokens.border.disabled, + backgroundColor: tokens.background.disabled, + }, + }, + }, + default: { + borderColor: tokens.border.default, + backgroundColor: tokens.background.surface, + selectors: { + '&:hover:not(:disabled)': { + borderColor: tokens.border.hover, + backgroundColor: tokens.background.surface, + }, + '&:active:not(:disabled)': { + borderColor: tokens.border.default, + backgroundColor: tokens.background.surface, + }, + '&:focus': { + borderColor: tokens.border.focus, + }, + '&:disabled': { + borderColor: tokens.border.disabled, + backgroundColor: tokens.background.disabled, + }, + }, + }, + }, +}; + +export const radioGroupBaseStyles = { + gap: tokens.spacing.md, +}; + +export const radioGroupVariants = { + orientation: { + vertical: { + flexDirection: 'column', + }, + horizontal: { + flexDirection: 'row', + }, + }, +}; + +export const radioIndicatorBaseStyles = { + backgroundColor: tokens.interactive.primary.default.background, + borderRadius: tokens['border-radius'].full, + width: tokens.sizing.xs, + height: tokens.sizing.xs, +}; + +export const radioIndicatorVariants = { + variant: { + error: { + backgroundColor: tokens.interactive.danger.default.background, + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: tokens.interactive.danger.hover.background, + }, + '&:active:not(:disabled)': { + backgroundColor: tokens.interactive.danger.active.background, + }, + '&:disabled': { + backgroundColor: tokens.interactive.danger.disabled.background, + }, + }, + }, + default: { + backgroundColor: tokens.interactive.primary.default.background, + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: tokens.interactive.primary.hover.background, + }, + '&:active:not(:disabled)': { + backgroundColor: tokens.interactive.primary.active.background, + }, + '&:disabled': { + backgroundColor: tokens.interactive.primary.disabled.background, + }, + }, + }, + }, +}; diff --git a/tokens/$themes.json b/tokens/$themes.json index 87a7197..f113a57 100644 --- a/tokens/$themes.json +++ b/tokens/$themes.json @@ -14,7 +14,8 @@ "4-components/toast": "enabled", "4-components/input": "enabled", "4-components/checkbox": "enabled", - "4-components/button": "enabled" + "4-components/button": "enabled", + "4-components/radio": "enabled" }, "$figmaStyleReferences": {}, "$figmaVariableReferences": {} @@ -34,7 +35,8 @@ "4-components/toast": "enabled", "4-components/input": "enabled", "4-components/checkbox": "enabled", - "4-components/button": "enabled" + "4-components/button": "enabled", + "4-components/radio": "enabled" }, "$figmaStyleReferences": {}, "$figmaVariableReferences": {} diff --git a/tokens/4-components/radio.json b/tokens/4-components/radio.json new file mode 100644 index 0000000..157b221 --- /dev/null +++ b/tokens/4-components/radio.json @@ -0,0 +1,228 @@ +{ + "radio": { + "base": { + "size": { + "$type": "sizing", + "$value": "{semantic.sizing.md}" + }, + "border-width": { + "$type": "borderWidth", + "$value": "{semantic.border-width.default}" + }, + "border-radius": { + "$type": "borderRadius", + "$value": "{semantic.border-radius.full}" + } + }, + "variants": { + "variant": { + "error": { + "border-color": { + "focus": { + "$type": "color", + "$value": "{semantic.interactive.danger.default.background}" + }, + "hover": { + "$type": "color", + "$value": "{semantic.interactive.danger.hover.background}" + }, + "default": { + "$type": "color", + "$value": "{semantic.interactive.danger.default.background}" + }, + "active": { + "$type": "color", + "$value": "{semantic.interactive.danger.active.background}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.border.disabled}" + } + }, + "background-color": { + "default": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "hover": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "active": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "focus": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.background.disabled}" + } + } + }, + "default": { + "border-color": { + "focus": { + "$type": "color", + "$value": "{semantic.border.focus}" + }, + "hover": { + "$type": "color", + "$value": "{semantic.border.hover}" + }, + "default": { + "$type": "color", + "$value": "{semantic.border.default}" + }, + "active": { + "$type": "color", + "$value": "{semantic.border.default}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.border.disabled}" + } + }, + "background-color": { + "hover": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "default": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "active": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "focus": { + "$type": "color", + "$value": "{semantic.background.surface}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.background.disabled}" + } + } + } + } + }, + "defaultVariants": { + "variant": { + "$type": "other", + "$value": "default" + } + } + }, + "radio-group": { + "base": { + "gap": { + "$type": "spacing", + "$value": "{semantic.spacing.md}" + } + }, + "variants": { + "orientation": { + "vertical": { + "flex-direction": { + "$type": "other", + "$value": "column" + } + }, + "horizontal": { + "flex-direction": { + "$type": "other", + "$value": "row" + } + } + } + }, + "defaultVariants": { + "orientation": { + "$type": "other", + "$value": "vertical" + } + } + }, + "radio-indicator": { + "base": { + "background-color": { + "$type": "color", + "$value": "{semantic.interactive.primary.default.background}" + }, + "border-radius": { + "$type": "borderRadius", + "$value": "{semantic.border-radius.full}" + }, + "width": { + "$type": "sizing", + "$value": "{semantic.sizing.xs}" + }, + "height": { + "$type": "sizing", + "$value": "{semantic.sizing.xs}" + } + }, + "variants": { + "variant": { + "error": { + "background-color": { + "default": { + "$type": "color", + "$value": "{semantic.interactive.danger.default.background}" + }, + "hover": { + "$type": "color", + "$value": "{semantic.interactive.danger.hover.background}" + }, + "active": { + "$type": "color", + "$value": "{semantic.interactive.danger.active.background}" + }, + "focus": { + "$type": "color", + "$value": "{semantic.interactive.danger.focus.background}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.interactive.danger.disabled.background}" + } + } + }, + "default": { + "background-color": { + "default": { + "$type": "color", + "$value": "{semantic.interactive.primary.default.background}" + }, + "hover": { + "$type": "color", + "$value": "{semantic.interactive.primary.hover.background}" + }, + "active": { + "$type": "color", + "$value": "{semantic.interactive.primary.active.background}" + }, + "focus": { + "$type": "color", + "$value": "{semantic.interactive.primary.focus.background}" + }, + "disabled": { + "$type": "color", + "$value": "{semantic.interactive.primary.disabled.background}" + } + } + } + } + }, + "defaultVariants": { + "variant": { + "$type": "other", + "$value": "default" + } + } + } +}