@fragments-sdk/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +44 -0
  2. package/src/brand.ts +15 -0
  3. package/src/components/Alert/Alert.fragment.tsx +163 -0
  4. package/src/components/Alert/Alert.module.scss +116 -0
  5. package/src/components/Alert/index.tsx +95 -0
  6. package/src/components/Avatar/Avatar.fragment.tsx +147 -0
  7. package/src/components/Avatar/Avatar.module.scss +136 -0
  8. package/src/components/Avatar/index.tsx +177 -0
  9. package/src/components/Badge/Badge.fragment.tsx +151 -0
  10. package/src/components/Badge/Badge.module.scss +87 -0
  11. package/src/components/Badge/index.tsx +55 -0
  12. package/src/components/Button/Button.fragment.tsx +159 -0
  13. package/src/components/Button/Button.module.scss +97 -0
  14. package/src/components/Button/index.tsx +51 -0
  15. package/src/components/Card/Card.fragment.tsx +156 -0
  16. package/src/components/Card/Card.module.scss +86 -0
  17. package/src/components/Card/index.tsx +79 -0
  18. package/src/components/Checkbox/Checkbox.fragment.tsx +166 -0
  19. package/src/components/Checkbox/Checkbox.module.scss +144 -0
  20. package/src/components/Checkbox/index.tsx +166 -0
  21. package/src/components/Dialog/Dialog.fragment.tsx +179 -0
  22. package/src/components/Dialog/Dialog.module.scss +158 -0
  23. package/src/components/Dialog/index.tsx +230 -0
  24. package/src/components/EmptyState/EmptyState.fragment.tsx +222 -0
  25. package/src/components/EmptyState/EmptyState.module.scss +120 -0
  26. package/src/components/EmptyState/index.tsx +80 -0
  27. package/src/components/Input/Input.fragment.tsx +174 -0
  28. package/src/components/Input/Input.module.scss +64 -0
  29. package/src/components/Input/index.tsx +76 -0
  30. package/src/components/Menu/Menu.fragment.tsx +168 -0
  31. package/src/components/Menu/Menu.module.scss +190 -0
  32. package/src/components/Menu/index.tsx +318 -0
  33. package/src/components/Popover/Popover.fragment.tsx +178 -0
  34. package/src/components/Popover/Popover.module.scss +165 -0
  35. package/src/components/Popover/index.tsx +229 -0
  36. package/src/components/Progress/Progress.fragment.tsx +142 -0
  37. package/src/components/Progress/Progress.module.scss +185 -0
  38. package/src/components/Progress/index.tsx +196 -0
  39. package/src/components/RadioGroup/RadioGroup.fragment.tsx +188 -0
  40. package/src/components/RadioGroup/RadioGroup.module.scss +155 -0
  41. package/src/components/RadioGroup/index.tsx +166 -0
  42. package/src/components/Select/Select.fragment.tsx +173 -0
  43. package/src/components/Select/Select.module.scss +187 -0
  44. package/src/components/Select/index.tsx +233 -0
  45. package/src/components/Separator/Separator.fragment.tsx +148 -0
  46. package/src/components/Separator/Separator.module.scss +92 -0
  47. package/src/components/Separator/index.tsx +89 -0
  48. package/src/components/Skeleton/Skeleton.fragment.tsx +147 -0
  49. package/src/components/Skeleton/Skeleton.module.scss +166 -0
  50. package/src/components/Skeleton/index.tsx +185 -0
  51. package/src/components/Table/Table.fragment.tsx +193 -0
  52. package/src/components/Table/Table.module.scss +152 -0
  53. package/src/components/Table/index.tsx +266 -0
  54. package/src/components/Tabs/Tabs.fragment.tsx +155 -0
  55. package/src/components/Tabs/Tabs.module.scss +142 -0
  56. package/src/components/Tabs/index.tsx +142 -0
  57. package/src/components/Textarea/Textarea.fragment.tsx +171 -0
  58. package/src/components/Textarea/Textarea.module.scss +89 -0
  59. package/src/components/Textarea/index.tsx +128 -0
  60. package/src/components/Toast/Toast.fragment.tsx +210 -0
  61. package/src/components/Toast/Toast.module.scss +227 -0
  62. package/src/components/Toast/index.tsx +315 -0
  63. package/src/components/Toggle/Toggle.fragment.tsx +174 -0
  64. package/src/components/Toggle/Toggle.module.scss +103 -0
  65. package/src/components/Toggle/index.tsx +80 -0
  66. package/src/components/Tooltip/Tooltip.fragment.tsx +158 -0
  67. package/src/components/Tooltip/Tooltip.module.scss +82 -0
  68. package/src/components/Tooltip/index.tsx +135 -0
  69. package/src/index.ts +151 -0
  70. package/src/scss.d.ts +4 -0
  71. package/src/styles/globals.scss +17 -0
  72. package/src/tokens/_mixins.scss +93 -0
  73. package/src/tokens/_variables.scss +276 -0
@@ -0,0 +1,196 @@
1
+ import * as React from 'react';
2
+ import { Progress as BaseProgress } from '@base-ui/react/progress';
3
+ import styles from './Progress.module.scss';
4
+ // Import globals to ensure CSS variables are defined
5
+ import '../../styles/globals.scss';
6
+
7
+ // ============================================
8
+ // Types
9
+ // ============================================
10
+
11
+ export interface ProgressProps {
12
+ /** Current progress value (0-100). Null for indeterminate. */
13
+ value?: number | null;
14
+ /** Minimum value */
15
+ min?: number;
16
+ /** Maximum value */
17
+ max?: number;
18
+ /** Size of the progress bar */
19
+ size?: 'sm' | 'md' | 'lg';
20
+ /** Color variant */
21
+ variant?: 'default' | 'success' | 'warning' | 'danger';
22
+ /** Label text */
23
+ label?: string;
24
+ /** Show percentage value */
25
+ showValue?: boolean;
26
+ /** Custom value formatter */
27
+ formatValue?: (value: number) => string;
28
+ /** Additional class name */
29
+ className?: string;
30
+ }
31
+
32
+ export interface CircularProgressProps {
33
+ /** Current progress value (0-100). Null for indeterminate. */
34
+ value?: number | null;
35
+ /** Size of the circular progress */
36
+ size?: 'sm' | 'md' | 'lg';
37
+ /** Color variant */
38
+ variant?: 'default' | 'success' | 'warning' | 'danger';
39
+ /** Show percentage in center */
40
+ showValue?: boolean;
41
+ /** Stroke width */
42
+ strokeWidth?: number;
43
+ /** Additional class name */
44
+ className?: string;
45
+ }
46
+
47
+ // ============================================
48
+ // Linear Progress
49
+ // ============================================
50
+
51
+ export function Progress({
52
+ value = null,
53
+ min = 0,
54
+ max = 100,
55
+ size = 'md',
56
+ variant = 'default',
57
+ label,
58
+ showValue = false,
59
+ formatValue,
60
+ className,
61
+ }: ProgressProps) {
62
+ const isIndeterminate = value === null;
63
+ const percentage = isIndeterminate ? 0 : Math.round(((value - min) / (max - min)) * 100);
64
+
65
+ const trackClasses = [
66
+ styles.track,
67
+ size === 'sm' && styles.trackSm,
68
+ size === 'md' && styles.trackMd,
69
+ size === 'lg' && styles.trackLg,
70
+ ].filter(Boolean).join(' ');
71
+
72
+ const indicatorClasses = [
73
+ styles.indicator,
74
+ variant === 'success' && styles.indicatorSuccess,
75
+ variant === 'warning' && styles.indicatorWarning,
76
+ variant === 'danger' && styles.indicatorDanger,
77
+ isIndeterminate && styles.indicatorIndeterminate,
78
+ ].filter(Boolean).join(' ');
79
+
80
+ const rootClasses = [styles.root, className].filter(Boolean).join(' ');
81
+
82
+ const displayValue = formatValue
83
+ ? formatValue(percentage)
84
+ : `${percentage}%`;
85
+
86
+ return (
87
+ <BaseProgress.Root
88
+ value={value}
89
+ min={min}
90
+ max={max}
91
+ className={rootClasses}
92
+ >
93
+ {(label || showValue) && (
94
+ <div className={styles.header}>
95
+ {label && (
96
+ <BaseProgress.Label className={styles.label}>
97
+ {label}
98
+ </BaseProgress.Label>
99
+ )}
100
+ {showValue && !isIndeterminate && (
101
+ <span className={styles.value}>{displayValue}</span>
102
+ )}
103
+ </div>
104
+ )}
105
+ <BaseProgress.Track className={trackClasses}>
106
+ <BaseProgress.Indicator
107
+ className={indicatorClasses}
108
+ style={isIndeterminate ? undefined : { width: `${percentage}%` }}
109
+ />
110
+ </BaseProgress.Track>
111
+ </BaseProgress.Root>
112
+ );
113
+ }
114
+
115
+ // ============================================
116
+ // Circular Progress
117
+ // ============================================
118
+
119
+ const CIRCLE_SIZES = {
120
+ sm: { size: 32, strokeWidth: 3 },
121
+ md: { size: 48, strokeWidth: 4 },
122
+ lg: { size: 64, strokeWidth: 5 },
123
+ };
124
+
125
+ export function CircularProgress({
126
+ value = null,
127
+ size = 'md',
128
+ variant = 'default',
129
+ showValue = false,
130
+ strokeWidth: customStrokeWidth,
131
+ className,
132
+ }: CircularProgressProps) {
133
+ const isIndeterminate = value === null;
134
+ const percentage = isIndeterminate ? 0 : Math.min(100, Math.max(0, value));
135
+
136
+ const { size: svgSize, strokeWidth: defaultStrokeWidth } = CIRCLE_SIZES[size];
137
+ const strokeWidth = customStrokeWidth ?? defaultStrokeWidth;
138
+
139
+ const radius = (svgSize - strokeWidth) / 2;
140
+ const circumference = 2 * Math.PI * radius;
141
+ const offset = circumference - (percentage / 100) * circumference;
142
+
143
+ const sizeClass = size === 'sm' ? styles.circularSm
144
+ : size === 'lg' ? styles.circularLg
145
+ : styles.circularMd;
146
+
147
+ const indicatorClasses = [
148
+ styles.circularIndicator,
149
+ variant === 'success' && styles.circularIndicatorSuccess,
150
+ variant === 'warning' && styles.circularIndicatorWarning,
151
+ variant === 'danger' && styles.circularIndicatorDanger,
152
+ isIndeterminate && styles.circularIndicatorIndeterminate,
153
+ ].filter(Boolean).join(' ');
154
+
155
+ const rootClasses = [styles.circular, sizeClass, className]
156
+ .filter(Boolean)
157
+ .join(' ');
158
+
159
+ return (
160
+ <BaseProgress.Root
161
+ value={value}
162
+ className={rootClasses}
163
+ aria-valuenow={isIndeterminate ? undefined : percentage}
164
+ >
165
+ <svg
166
+ className={styles.circularSvg}
167
+ width={svgSize}
168
+ height={svgSize}
169
+ viewBox={`0 0 ${svgSize} ${svgSize}`}
170
+ >
171
+ {/* Track circle */}
172
+ <circle
173
+ className={styles.circularTrack}
174
+ cx={svgSize / 2}
175
+ cy={svgSize / 2}
176
+ r={radius}
177
+ strokeWidth={strokeWidth}
178
+ />
179
+ {/* Indicator circle */}
180
+ <circle
181
+ className={indicatorClasses}
182
+ cx={svgSize / 2}
183
+ cy={svgSize / 2}
184
+ r={radius}
185
+ strokeWidth={strokeWidth}
186
+ strokeDasharray={circumference}
187
+ strokeDashoffset={isIndeterminate ? undefined : offset}
188
+ style={isIndeterminate ? { transformOrigin: 'center' } : undefined}
189
+ />
190
+ </svg>
191
+ {showValue && !isIndeterminate && (
192
+ <span className={styles.circularValue}>{Math.round(percentage)}%</span>
193
+ )}
194
+ </BaseProgress.Root>
195
+ );
196
+ }
@@ -0,0 +1,188 @@
1
+ import React from 'react';
2
+ import { defineSegment } from '@fragments/core';
3
+ import { RadioGroup } from './index.js';
4
+
5
+ export default defineSegment({
6
+ component: RadioGroup,
7
+
8
+ meta: {
9
+ name: 'RadioGroup',
10
+ description: 'Single selection from a list of mutually exclusive options',
11
+ category: 'forms',
12
+ status: 'stable',
13
+ tags: ['form', 'radio', 'selection', 'options'],
14
+ },
15
+
16
+ usage: {
17
+ when: [
18
+ 'User must select exactly one option from a small set',
19
+ 'Options are mutually exclusive',
20
+ 'All options should be visible at once',
21
+ '2-5 options available',
22
+ ],
23
+ whenNot: [
24
+ 'Multiple selections allowed (use Checkbox group)',
25
+ 'Many options (use Select)',
26
+ 'Binary on/off choice (use Toggle/Switch)',
27
+ 'Options need to be searchable (use Combobox)',
28
+ ],
29
+ guidelines: [
30
+ 'Always have one option pre-selected when possible',
31
+ 'Order options logically (alphabetical, frequency, etc.)',
32
+ 'Keep option labels concise',
33
+ 'Use descriptions for complex options',
34
+ ],
35
+ accessibility: [
36
+ 'Group must have an accessible label',
37
+ 'Use arrow keys to navigate between options',
38
+ 'Selected option should be clearly indicated',
39
+ ],
40
+ },
41
+
42
+ props: {
43
+ value: {
44
+ type: 'string',
45
+ description: 'Controlled selected value',
46
+ },
47
+ defaultValue: {
48
+ type: 'string',
49
+ description: 'Default value (uncontrolled)',
50
+ },
51
+ onValueChange: {
52
+ type: 'function',
53
+ description: 'Callback when selection changes',
54
+ },
55
+ orientation: {
56
+ type: 'enum',
57
+ values: ['horizontal', 'vertical'],
58
+ default: 'vertical',
59
+ description: 'Layout orientation',
60
+ },
61
+ size: {
62
+ type: 'enum',
63
+ values: ['sm', 'md', 'lg'],
64
+ default: 'md',
65
+ description: 'Size variant',
66
+ },
67
+ disabled: {
68
+ type: 'boolean',
69
+ default: false,
70
+ description: 'Disable all options',
71
+ },
72
+ label: {
73
+ type: 'string',
74
+ description: 'Group label',
75
+ },
76
+ error: {
77
+ type: 'string',
78
+ description: 'Error message',
79
+ },
80
+ },
81
+
82
+ relations: [
83
+ {
84
+ component: 'Checkbox',
85
+ relationship: 'alternative',
86
+ note: 'Use Checkbox for multiple selections',
87
+ },
88
+ {
89
+ component: 'Select',
90
+ relationship: 'alternative',
91
+ note: 'Use Select for many options or limited space',
92
+ },
93
+ {
94
+ component: 'Toggle',
95
+ relationship: 'alternative',
96
+ note: 'Use Toggle for binary on/off choices',
97
+ },
98
+ ],
99
+
100
+ contract: {
101
+ propsSummary: [
102
+ 'value: string - selected value',
103
+ 'onValueChange: (value: string) => void',
104
+ 'orientation: horizontal|vertical (default: vertical)',
105
+ 'size: sm|md|lg (default: md)',
106
+ 'disabled: boolean - disable all options',
107
+ ],
108
+ scenarioTags: [
109
+ 'form.selection',
110
+ 'form.preference',
111
+ 'form.option',
112
+ ],
113
+ a11yRules: [
114
+ 'A11Y_RADIO_GROUP',
115
+ 'A11Y_LABEL_REQUIRED',
116
+ ],
117
+ bans: [],
118
+ },
119
+
120
+ variants: [
121
+ {
122
+ name: 'Default',
123
+ description: 'Basic radio group with labels',
124
+ render: () => (
125
+ <RadioGroup defaultValue="option1" label="Select an option">
126
+ <RadioGroup.Item value="option1" label="Option 1" />
127
+ <RadioGroup.Item value="option2" label="Option 2" />
128
+ <RadioGroup.Item value="option3" label="Option 3" />
129
+ </RadioGroup>
130
+ ),
131
+ },
132
+ {
133
+ name: 'With Descriptions',
134
+ description: 'Radio items with additional context',
135
+ render: () => (
136
+ <RadioGroup defaultValue="standard" label="Shipping Method">
137
+ <RadioGroup.Item
138
+ value="standard"
139
+ label="Standard"
140
+ description="5-7 business days"
141
+ />
142
+ <RadioGroup.Item
143
+ value="express"
144
+ label="Express"
145
+ description="2-3 business days"
146
+ />
147
+ <RadioGroup.Item
148
+ value="overnight"
149
+ label="Overnight"
150
+ description="Next business day"
151
+ />
152
+ </RadioGroup>
153
+ ),
154
+ },
155
+ {
156
+ name: 'Horizontal',
157
+ description: 'Side-by-side layout',
158
+ render: () => (
159
+ <RadioGroup orientation="horizontal" defaultValue="small" label="Size">
160
+ <RadioGroup.Item value="small" label="S" />
161
+ <RadioGroup.Item value="medium" label="M" />
162
+ <RadioGroup.Item value="large" label="L" />
163
+ <RadioGroup.Item value="xlarge" label="XL" />
164
+ </RadioGroup>
165
+ ),
166
+ },
167
+ {
168
+ name: 'With Error',
169
+ description: 'Validation error state',
170
+ render: () => (
171
+ <RadioGroup label="Required selection" error="Please select an option">
172
+ <RadioGroup.Item value="a" label="Option A" />
173
+ <RadioGroup.Item value="b" label="Option B" />
174
+ </RadioGroup>
175
+ ),
176
+ },
177
+ {
178
+ name: 'Disabled',
179
+ description: 'Non-interactive state',
180
+ render: () => (
181
+ <RadioGroup disabled defaultValue="locked" label="Locked selection">
182
+ <RadioGroup.Item value="locked" label="This is locked" />
183
+ <RadioGroup.Item value="other" label="Cannot select" />
184
+ </RadioGroup>
185
+ ),
186
+ },
187
+ ],
188
+ });
@@ -0,0 +1,155 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ // Container wrapper
5
+ .wrapper {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--fui-space-2, $fui-space-2);
9
+ }
10
+
11
+ .groupLabel {
12
+ @include label-text;
13
+ }
14
+
15
+ // Radio group
16
+ .group {
17
+ display: flex;
18
+ gap: var(--fui-space-3, $fui-space-3);
19
+ }
20
+
21
+ .vertical {
22
+ flex-direction: column;
23
+ }
24
+
25
+ .horizontal {
26
+ flex-direction: row;
27
+ flex-wrap: wrap;
28
+ }
29
+
30
+ // Individual item wrapper
31
+ .itemWrapper {
32
+ display: inline-flex;
33
+ align-items: flex-start;
34
+ gap: var(--fui-space-2, $fui-space-2);
35
+ cursor: pointer;
36
+ font-family: var(--fui-font-sans, $fui-font-sans);
37
+
38
+ &[data-disabled] {
39
+ cursor: not-allowed;
40
+ opacity: 0.5;
41
+ }
42
+ }
43
+
44
+ // The radio circle
45
+ .radio {
46
+ @include interactive-base;
47
+
48
+ position: relative;
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ flex-shrink: 0;
53
+ width: 1rem;
54
+ height: 1rem;
55
+ margin-top: 2px;
56
+ background-color: var(--fui-bg-elevated, $fui-bg-elevated);
57
+ border: 1px solid var(--fui-border-strong, $fui-border-strong);
58
+ border-radius: var(--fui-radius-full, $fui-radius-full);
59
+ cursor: inherit;
60
+
61
+ &:hover:not([data-disabled]) {
62
+ border-color: var(--fui-text-tertiary, $fui-text-tertiary);
63
+ }
64
+
65
+ &[data-checked] {
66
+ background-color: var(--fui-bg-elevated, $fui-bg-elevated);
67
+ border-color: var(--fui-color-accent, $fui-color-accent);
68
+ }
69
+
70
+ &[data-checked]:hover:not([data-disabled]) {
71
+ border-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
72
+ }
73
+
74
+ &[data-invalid] {
75
+ border-color: var(--fui-color-danger, $fui-color-danger);
76
+ }
77
+ }
78
+
79
+ // Size variants
80
+ .radioSm {
81
+ width: 0.875rem;
82
+ height: 0.875rem;
83
+ margin-top: 3px;
84
+ }
85
+
86
+ .radioLg {
87
+ width: 1.25rem;
88
+ height: 1.25rem;
89
+ margin-top: 0;
90
+ }
91
+
92
+ // The indicator dot
93
+ .indicator {
94
+ width: 0.5rem;
95
+ height: 0.5rem;
96
+ background-color: var(--fui-color-accent, $fui-color-accent);
97
+ border-radius: var(--fui-radius-full, $fui-radius-full);
98
+
99
+ // Animation
100
+ opacity: 0;
101
+ transform: scale(0);
102
+ transition:
103
+ opacity var(--fui-transition-fast, $fui-transition-fast),
104
+ transform var(--fui-transition-fast, $fui-transition-fast);
105
+
106
+ [data-checked] > & {
107
+ opacity: 1;
108
+ transform: scale(1);
109
+ }
110
+ }
111
+
112
+ .radioSm .indicator {
113
+ width: 0.375rem;
114
+ height: 0.375rem;
115
+ }
116
+
117
+ .radioLg .indicator {
118
+ width: 0.625rem;
119
+ height: 0.625rem;
120
+ }
121
+
122
+ // Label content
123
+ .content {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 2px;
127
+ }
128
+
129
+ .label {
130
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
131
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
132
+ color: var(--fui-text-primary, $fui-text-primary);
133
+ line-height: var(--fui-line-height-tight, $fui-line-height-tight);
134
+ user-select: none;
135
+ }
136
+
137
+ .labelSm {
138
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
139
+ }
140
+
141
+ .labelLg {
142
+ font-size: var(--fui-font-size-base, $fui-font-size-base);
143
+ }
144
+
145
+ .description {
146
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
147
+ color: var(--fui-text-secondary, $fui-text-secondary);
148
+ line-height: var(--fui-line-height-normal, $fui-line-height-normal);
149
+ }
150
+
151
+ // Error message
152
+ .error {
153
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
154
+ color: var(--fui-color-danger, $fui-color-danger);
155
+ }
@@ -0,0 +1,166 @@
1
+ import * as React from 'react';
2
+ import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
3
+ import { Radio as BaseRadio } from '@base-ui/react/radio';
4
+ import styles from './RadioGroup.module.scss';
5
+ // Import globals to ensure CSS variables are defined
6
+ import '../../styles/globals.scss';
7
+
8
+ // ============================================
9
+ // Types
10
+ // ============================================
11
+
12
+ export interface RadioGroupProps {
13
+ /** Current value (controlled) */
14
+ value?: string;
15
+ /** Default value (uncontrolled) */
16
+ defaultValue?: string;
17
+ /** Callback when value changes */
18
+ onValueChange?: (value: string) => void;
19
+ /** Orientation of the radio group */
20
+ orientation?: 'horizontal' | 'vertical';
21
+ /** Whether the group is disabled */
22
+ disabled?: boolean;
23
+ /** Form field name */
24
+ name?: string;
25
+ /** Label for the group */
26
+ label?: string;
27
+ /** Error message */
28
+ error?: string;
29
+ /** Size variant */
30
+ size?: 'sm' | 'md' | 'lg';
31
+ /** Children (Radio.Item components) */
32
+ children: React.ReactNode;
33
+ /** Additional class name */
34
+ className?: string;
35
+ }
36
+
37
+ export interface RadioItemProps {
38
+ /** The value for this radio item */
39
+ value: string;
40
+ /** Label text */
41
+ label?: string;
42
+ /** Description text below the label */
43
+ description?: string;
44
+ /** Whether this item is disabled */
45
+ disabled?: boolean;
46
+ /** Additional class name */
47
+ className?: string;
48
+ }
49
+
50
+ // ============================================
51
+ // Context for size
52
+ // ============================================
53
+
54
+ const RadioSizeContext = React.createContext<'sm' | 'md' | 'lg'>('md');
55
+
56
+ // ============================================
57
+ // Radio Item Component
58
+ // ============================================
59
+
60
+ function RadioItem({
61
+ value,
62
+ label,
63
+ description,
64
+ disabled = false,
65
+ className,
66
+ }: RadioItemProps) {
67
+ const size = React.useContext(RadioSizeContext);
68
+
69
+ const radioClasses = [
70
+ styles.radio,
71
+ size === 'sm' && styles.radioSm,
72
+ size === 'lg' && styles.radioLg,
73
+ ].filter(Boolean).join(' ');
74
+
75
+ const labelClasses = [
76
+ styles.label,
77
+ size === 'sm' && styles.labelSm,
78
+ size === 'lg' && styles.labelLg,
79
+ ].filter(Boolean).join(' ');
80
+
81
+ const wrapperClasses = [styles.itemWrapper, className].filter(Boolean).join(' ');
82
+
83
+ // If no label/description, render just the radio
84
+ if (!label && !description) {
85
+ return (
86
+ <BaseRadio.Root
87
+ value={value}
88
+ disabled={disabled}
89
+ className={[radioClasses, className].filter(Boolean).join(' ')}
90
+ >
91
+ <BaseRadio.Indicator className={styles.indicator} />
92
+ </BaseRadio.Root>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <label className={wrapperClasses} data-disabled={disabled || undefined}>
98
+ <BaseRadio.Root
99
+ value={value}
100
+ disabled={disabled}
101
+ className={radioClasses}
102
+ >
103
+ <BaseRadio.Indicator className={styles.indicator} />
104
+ </BaseRadio.Root>
105
+ <div className={styles.content}>
106
+ <span className={labelClasses}>{label}</span>
107
+ {description && (
108
+ <span className={styles.description}>{description}</span>
109
+ )}
110
+ </div>
111
+ </label>
112
+ );
113
+ }
114
+
115
+ // ============================================
116
+ // Radio Group Component
117
+ // ============================================
118
+
119
+ function RadioGroupRoot({
120
+ value,
121
+ defaultValue,
122
+ onValueChange,
123
+ orientation = 'vertical',
124
+ disabled = false,
125
+ name,
126
+ label,
127
+ error,
128
+ size = 'md',
129
+ children,
130
+ className,
131
+ }: RadioGroupProps) {
132
+ const groupClasses = [
133
+ styles.group,
134
+ styles[orientation],
135
+ className,
136
+ ].filter(Boolean).join(' ');
137
+
138
+ return (
139
+ <RadioSizeContext.Provider value={size}>
140
+ <div className={styles.wrapper}>
141
+ {label && <span className={styles.groupLabel}>{label}</span>}
142
+ <BaseRadioGroup
143
+ value={value}
144
+ defaultValue={defaultValue}
145
+ onValueChange={onValueChange}
146
+ disabled={disabled}
147
+ name={name}
148
+ className={groupClasses}
149
+ >
150
+ {children}
151
+ </BaseRadioGroup>
152
+ {error && <span className={styles.error}>{error}</span>}
153
+ </div>
154
+ </RadioSizeContext.Provider>
155
+ );
156
+ }
157
+
158
+ // ============================================
159
+ // Compound Component Export
160
+ // ============================================
161
+
162
+ export const RadioGroup = Object.assign(RadioGroupRoot, {
163
+ Item: RadioItem,
164
+ });
165
+
166
+ export type { RadioGroupProps as RadioGroupRootProps };