@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.
- package/package.json +44 -0
- package/src/brand.ts +15 -0
- package/src/components/Alert/Alert.fragment.tsx +163 -0
- package/src/components/Alert/Alert.module.scss +116 -0
- package/src/components/Alert/index.tsx +95 -0
- package/src/components/Avatar/Avatar.fragment.tsx +147 -0
- package/src/components/Avatar/Avatar.module.scss +136 -0
- package/src/components/Avatar/index.tsx +177 -0
- package/src/components/Badge/Badge.fragment.tsx +151 -0
- package/src/components/Badge/Badge.module.scss +87 -0
- package/src/components/Badge/index.tsx +55 -0
- package/src/components/Button/Button.fragment.tsx +159 -0
- package/src/components/Button/Button.module.scss +97 -0
- package/src/components/Button/index.tsx +51 -0
- package/src/components/Card/Card.fragment.tsx +156 -0
- package/src/components/Card/Card.module.scss +86 -0
- package/src/components/Card/index.tsx +79 -0
- package/src/components/Checkbox/Checkbox.fragment.tsx +166 -0
- package/src/components/Checkbox/Checkbox.module.scss +144 -0
- package/src/components/Checkbox/index.tsx +166 -0
- package/src/components/Dialog/Dialog.fragment.tsx +179 -0
- package/src/components/Dialog/Dialog.module.scss +158 -0
- package/src/components/Dialog/index.tsx +230 -0
- package/src/components/EmptyState/EmptyState.fragment.tsx +222 -0
- package/src/components/EmptyState/EmptyState.module.scss +120 -0
- package/src/components/EmptyState/index.tsx +80 -0
- package/src/components/Input/Input.fragment.tsx +174 -0
- package/src/components/Input/Input.module.scss +64 -0
- package/src/components/Input/index.tsx +76 -0
- package/src/components/Menu/Menu.fragment.tsx +168 -0
- package/src/components/Menu/Menu.module.scss +190 -0
- package/src/components/Menu/index.tsx +318 -0
- package/src/components/Popover/Popover.fragment.tsx +178 -0
- package/src/components/Popover/Popover.module.scss +165 -0
- package/src/components/Popover/index.tsx +229 -0
- package/src/components/Progress/Progress.fragment.tsx +142 -0
- package/src/components/Progress/Progress.module.scss +185 -0
- package/src/components/Progress/index.tsx +196 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +188 -0
- package/src/components/RadioGroup/RadioGroup.module.scss +155 -0
- package/src/components/RadioGroup/index.tsx +166 -0
- package/src/components/Select/Select.fragment.tsx +173 -0
- package/src/components/Select/Select.module.scss +187 -0
- package/src/components/Select/index.tsx +233 -0
- package/src/components/Separator/Separator.fragment.tsx +148 -0
- package/src/components/Separator/Separator.module.scss +92 -0
- package/src/components/Separator/index.tsx +89 -0
- package/src/components/Skeleton/Skeleton.fragment.tsx +147 -0
- package/src/components/Skeleton/Skeleton.module.scss +166 -0
- package/src/components/Skeleton/index.tsx +185 -0
- package/src/components/Table/Table.fragment.tsx +193 -0
- package/src/components/Table/Table.module.scss +152 -0
- package/src/components/Table/index.tsx +266 -0
- package/src/components/Tabs/Tabs.fragment.tsx +155 -0
- package/src/components/Tabs/Tabs.module.scss +142 -0
- package/src/components/Tabs/index.tsx +142 -0
- package/src/components/Textarea/Textarea.fragment.tsx +171 -0
- package/src/components/Textarea/Textarea.module.scss +89 -0
- package/src/components/Textarea/index.tsx +128 -0
- package/src/components/Toast/Toast.fragment.tsx +210 -0
- package/src/components/Toast/Toast.module.scss +227 -0
- package/src/components/Toast/index.tsx +315 -0
- package/src/components/Toggle/Toggle.fragment.tsx +174 -0
- package/src/components/Toggle/Toggle.module.scss +103 -0
- package/src/components/Toggle/index.tsx +80 -0
- package/src/components/Tooltip/Tooltip.fragment.tsx +158 -0
- package/src/components/Tooltip/Tooltip.module.scss +82 -0
- package/src/components/Tooltip/index.tsx +135 -0
- package/src/index.ts +151 -0
- package/src/scss.d.ts +4 -0
- package/src/styles/globals.scss +17 -0
- package/src/tokens/_mixins.scss +93 -0
- 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 };
|