@choice-ui/radio 0.0.4
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/README.md +434 -0
- package/dist/index.cjs +341 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +335 -0
- package/package.json +47 -0
- package/src/context.ts +38 -0
- package/src/index.ts +8 -0
- package/src/radio-group.tsx +137 -0
- package/src/radio-label.tsx +33 -0
- package/src/radio.tsx +109 -0
- package/src/tv.ts +128 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { tcv, tcx } from '@choice-ui/shared';
|
|
2
|
+
import { Dot } from '@choiceform/icons-react';
|
|
3
|
+
import { createContext, memo, forwardRef, useId, useMemo, useContext } from 'react';
|
|
4
|
+
import { useEventCallback } from 'usehooks-ts';
|
|
5
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
// src/radio.tsx
|
|
8
|
+
var RadioContext = createContext(null);
|
|
9
|
+
function useRadioContext() {
|
|
10
|
+
const context = useContext(RadioContext);
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error("Radio.Label must be used within a Radio component");
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
}
|
|
16
|
+
var RadioGroupContext = createContext(null);
|
|
17
|
+
function useRadioGroupContext() {
|
|
18
|
+
const context = useContext(RadioGroupContext);
|
|
19
|
+
if (!context) {
|
|
20
|
+
throw new Error("RadioGroupItem must be used within a RadioGroup");
|
|
21
|
+
}
|
|
22
|
+
return context;
|
|
23
|
+
}
|
|
24
|
+
var radioTv = tcv({
|
|
25
|
+
slots: {
|
|
26
|
+
root: "flex items-center select-none",
|
|
27
|
+
box: ["relative flex size-4 items-center justify-center", "border border-solid"],
|
|
28
|
+
input: "peer pointer-events-auto absolute inset-0 appearance-none opacity-0",
|
|
29
|
+
label: "pl-2"
|
|
30
|
+
},
|
|
31
|
+
variants: {
|
|
32
|
+
type: {
|
|
33
|
+
checkbox: {
|
|
34
|
+
box: "rounded-md"
|
|
35
|
+
},
|
|
36
|
+
radio: {
|
|
37
|
+
box: "rounded-full"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
variant: {
|
|
41
|
+
default: {},
|
|
42
|
+
accent: {},
|
|
43
|
+
outline: {}
|
|
44
|
+
},
|
|
45
|
+
checked: {
|
|
46
|
+
true: {},
|
|
47
|
+
false: {}
|
|
48
|
+
},
|
|
49
|
+
disabled: {
|
|
50
|
+
true: {},
|
|
51
|
+
false: {}
|
|
52
|
+
},
|
|
53
|
+
focused: {
|
|
54
|
+
true: {},
|
|
55
|
+
false: {}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
compoundVariants: [
|
|
59
|
+
// 未选中状态
|
|
60
|
+
{
|
|
61
|
+
variant: ["default", "accent"],
|
|
62
|
+
checked: false,
|
|
63
|
+
disabled: false,
|
|
64
|
+
focused: false,
|
|
65
|
+
class: {
|
|
66
|
+
box: "bg-secondary-background border-default-boundary"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
variant: "outline",
|
|
71
|
+
checked: false,
|
|
72
|
+
disabled: false,
|
|
73
|
+
focused: false,
|
|
74
|
+
class: {
|
|
75
|
+
box: ["border-default-foreground", "peer-focus-visible:border-selected-boundary"]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
// 选中状态 - default
|
|
79
|
+
{
|
|
80
|
+
variant: "default",
|
|
81
|
+
checked: true,
|
|
82
|
+
disabled: false,
|
|
83
|
+
focused: false,
|
|
84
|
+
class: {
|
|
85
|
+
box: [
|
|
86
|
+
"bg-secondary-background border-default-boundary",
|
|
87
|
+
"peer-focus-visible:border-selected-strong-boundary"
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
// 选中状态 - accent & outline
|
|
92
|
+
{
|
|
93
|
+
variant: ["accent", "outline"],
|
|
94
|
+
checked: true,
|
|
95
|
+
disabled: false,
|
|
96
|
+
focused: false,
|
|
97
|
+
class: {
|
|
98
|
+
box: [
|
|
99
|
+
"bg-accent-background border-selected-strong-boundary text-on-accent-foreground",
|
|
100
|
+
"peer-focus-visible:border-selected-strong-boundary",
|
|
101
|
+
"peer-focus-visible:text-on-accent-foreground",
|
|
102
|
+
"peer-focus-visible:shadow-checked-focused"
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
variant: ["default", "accent", "outline"],
|
|
108
|
+
checked: false,
|
|
109
|
+
disabled: false,
|
|
110
|
+
focused: true,
|
|
111
|
+
class: {
|
|
112
|
+
box: "border-selected-boundary"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
variant: "default",
|
|
117
|
+
checked: true,
|
|
118
|
+
disabled: false,
|
|
119
|
+
focused: true,
|
|
120
|
+
class: {
|
|
121
|
+
box: "border-selected-strong-boundary"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
variant: ["accent", "outline"],
|
|
126
|
+
checked: true,
|
|
127
|
+
disabled: false,
|
|
128
|
+
focused: true,
|
|
129
|
+
class: {
|
|
130
|
+
box: "text-on-accent-foreground border-selected-strong-boundary shadow-checked-focused"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
variant: ["accent", "outline", "default"],
|
|
135
|
+
disabled: true,
|
|
136
|
+
class: {
|
|
137
|
+
root: "text-default-background",
|
|
138
|
+
box: "border-disabled-background bg-disabled-background",
|
|
139
|
+
label: "text-disabled-foreground"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
defaultVariants: {
|
|
144
|
+
variant: "default",
|
|
145
|
+
checked: false,
|
|
146
|
+
disabled: false,
|
|
147
|
+
focused: false
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
var RadioLabel = memo(
|
|
151
|
+
forwardRef(function RadioLabel2(props, ref) {
|
|
152
|
+
const { children, className, ...rest } = props;
|
|
153
|
+
const { id, descriptionId, disabled } = useRadioContext();
|
|
154
|
+
const styles = radioTv({ disabled });
|
|
155
|
+
return /* @__PURE__ */ jsx(
|
|
156
|
+
"label",
|
|
157
|
+
{
|
|
158
|
+
ref,
|
|
159
|
+
id: descriptionId,
|
|
160
|
+
htmlFor: id,
|
|
161
|
+
className: tcx(styles.label(), className),
|
|
162
|
+
...rest,
|
|
163
|
+
children
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
RadioLabel.displayName = "Radio.Label";
|
|
169
|
+
var RadioBase = forwardRef(function Radio(props, ref) {
|
|
170
|
+
const {
|
|
171
|
+
value,
|
|
172
|
+
onChange,
|
|
173
|
+
disabled,
|
|
174
|
+
readOnly = false,
|
|
175
|
+
name,
|
|
176
|
+
variant = "default",
|
|
177
|
+
className,
|
|
178
|
+
focused,
|
|
179
|
+
children,
|
|
180
|
+
"aria-label": ariaLabel,
|
|
181
|
+
"aria-describedby": ariaDescribedby,
|
|
182
|
+
onKeyDown,
|
|
183
|
+
...rest
|
|
184
|
+
} = props;
|
|
185
|
+
const id = useId();
|
|
186
|
+
const descriptionId = useId();
|
|
187
|
+
const styles = radioTv({
|
|
188
|
+
type: "radio",
|
|
189
|
+
variant,
|
|
190
|
+
disabled,
|
|
191
|
+
checked: value,
|
|
192
|
+
focused
|
|
193
|
+
});
|
|
194
|
+
const handleChange = useEventCallback((e) => {
|
|
195
|
+
if (readOnly) return;
|
|
196
|
+
onChange(e.target.checked);
|
|
197
|
+
});
|
|
198
|
+
const handleKeyDown = useEventCallback((e) => {
|
|
199
|
+
if (readOnly) return;
|
|
200
|
+
if (e.key === " " || e.key === "Enter") {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
onChange(!value);
|
|
203
|
+
}
|
|
204
|
+
onKeyDown?.(e);
|
|
205
|
+
});
|
|
206
|
+
const renderChildren = () => {
|
|
207
|
+
if (typeof children === "string" || typeof children === "number") {
|
|
208
|
+
return /* @__PURE__ */ jsx(RadioLabel, { children });
|
|
209
|
+
}
|
|
210
|
+
return children;
|
|
211
|
+
};
|
|
212
|
+
return /* @__PURE__ */ jsx(RadioContext.Provider, { value: { id, descriptionId, disabled }, children: /* @__PURE__ */ jsxs("div", { className: tcx(styles.root(), className), children: [
|
|
213
|
+
/* @__PURE__ */ jsxs("div", { className: "pointer-events-none relative", children: [
|
|
214
|
+
/* @__PURE__ */ jsx(
|
|
215
|
+
"input",
|
|
216
|
+
{
|
|
217
|
+
ref,
|
|
218
|
+
className: styles.input(),
|
|
219
|
+
type: "radio",
|
|
220
|
+
id,
|
|
221
|
+
name,
|
|
222
|
+
checked: value,
|
|
223
|
+
disabled: disabled || readOnly,
|
|
224
|
+
onChange: handleChange,
|
|
225
|
+
"aria-label": ariaLabel,
|
|
226
|
+
"aria-describedby": ariaDescribedby || descriptionId,
|
|
227
|
+
"aria-checked": value,
|
|
228
|
+
"aria-disabled": disabled || readOnly,
|
|
229
|
+
role: "radio",
|
|
230
|
+
onKeyDown: handleKeyDown,
|
|
231
|
+
...rest
|
|
232
|
+
}
|
|
233
|
+
),
|
|
234
|
+
/* @__PURE__ */ jsx("div", { className: styles.box(), children: value && /* @__PURE__ */ jsx(Dot, {}) })
|
|
235
|
+
] }),
|
|
236
|
+
renderChildren()
|
|
237
|
+
] }) });
|
|
238
|
+
});
|
|
239
|
+
var MemoizedRadio = memo(RadioBase);
|
|
240
|
+
var Radio2 = MemoizedRadio;
|
|
241
|
+
Radio2.Label = RadioLabel;
|
|
242
|
+
Radio2.displayName = "Radio";
|
|
243
|
+
var RadioGroupItem = memo(
|
|
244
|
+
forwardRef(function RadioGroupItem2(props, ref) {
|
|
245
|
+
const { value, children, className, disabled, ...rest } = props;
|
|
246
|
+
const {
|
|
247
|
+
name,
|
|
248
|
+
value: selectedValue,
|
|
249
|
+
onChange,
|
|
250
|
+
variant,
|
|
251
|
+
readOnly: contextReadonly
|
|
252
|
+
} = useRadioGroupContext();
|
|
253
|
+
const isChecked = selectedValue === value;
|
|
254
|
+
const handleChange = useEventCallback(() => {
|
|
255
|
+
if (contextReadonly) return;
|
|
256
|
+
onChange(value);
|
|
257
|
+
});
|
|
258
|
+
return /* @__PURE__ */ jsx(
|
|
259
|
+
Radio2,
|
|
260
|
+
{
|
|
261
|
+
name,
|
|
262
|
+
value: isChecked,
|
|
263
|
+
disabled,
|
|
264
|
+
readOnly: contextReadonly,
|
|
265
|
+
variant,
|
|
266
|
+
onChange: handleChange,
|
|
267
|
+
className,
|
|
268
|
+
...rest,
|
|
269
|
+
ref,
|
|
270
|
+
children: /* @__PURE__ */ jsx(Radio2.Label, { children })
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
RadioGroupItem.displayName = "RadioGroup.Item";
|
|
276
|
+
var RadioGroupBase = forwardRef(function RadioGroup(props, ref) {
|
|
277
|
+
const {
|
|
278
|
+
className,
|
|
279
|
+
options,
|
|
280
|
+
value,
|
|
281
|
+
onChange,
|
|
282
|
+
disabled,
|
|
283
|
+
readOnly = false,
|
|
284
|
+
variant = "default",
|
|
285
|
+
children,
|
|
286
|
+
...rest
|
|
287
|
+
} = props;
|
|
288
|
+
const id = useId();
|
|
289
|
+
const handleChange = useEventCallback((newValue) => {
|
|
290
|
+
if (readOnly) return;
|
|
291
|
+
onChange(newValue);
|
|
292
|
+
});
|
|
293
|
+
const contextValue = useMemo(
|
|
294
|
+
() => ({
|
|
295
|
+
name: id,
|
|
296
|
+
value,
|
|
297
|
+
onChange: handleChange,
|
|
298
|
+
disabled,
|
|
299
|
+
readOnly,
|
|
300
|
+
variant
|
|
301
|
+
}),
|
|
302
|
+
[id, value, handleChange, disabled, readOnly, variant]
|
|
303
|
+
);
|
|
304
|
+
const renderOptionsRadios = useMemo(() => {
|
|
305
|
+
if (!options) return null;
|
|
306
|
+
return options.map((option) => /* @__PURE__ */ jsx(
|
|
307
|
+
RadioGroupItem,
|
|
308
|
+
{
|
|
309
|
+
value: option.value,
|
|
310
|
+
disabled: option.disabled || disabled,
|
|
311
|
+
variant,
|
|
312
|
+
children: option.label
|
|
313
|
+
},
|
|
314
|
+
option.value
|
|
315
|
+
));
|
|
316
|
+
}, [disabled, options, variant]);
|
|
317
|
+
return /* @__PURE__ */ jsx(RadioGroupContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
|
|
318
|
+
"div",
|
|
319
|
+
{
|
|
320
|
+
className: tcx("flex flex-col gap-2", className),
|
|
321
|
+
ref,
|
|
322
|
+
role: "radiogroup",
|
|
323
|
+
"aria-labelledby": props["aria-labelledby"],
|
|
324
|
+
"aria-label": props["aria-label"],
|
|
325
|
+
...rest,
|
|
326
|
+
children: options ? renderOptionsRadios : children
|
|
327
|
+
}
|
|
328
|
+
) });
|
|
329
|
+
});
|
|
330
|
+
var MemoizedRadioGroup = memo(RadioGroupBase);
|
|
331
|
+
var RadioGroup2 = MemoizedRadioGroup;
|
|
332
|
+
RadioGroup2.Item = RadioGroupItem;
|
|
333
|
+
RadioGroup2.displayName = "RadioGroup";
|
|
334
|
+
|
|
335
|
+
export { Radio2 as Radio, RadioGroup2 as RadioGroup, RadioLabel, useRadioContext, useRadioGroupContext };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@choice-ui/radio",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "Radio component for Choiceform Design System",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"require": "./dist/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"usehooks-ts": "^3.1.0",
|
|
26
|
+
"@choiceform/icons-react": "^1.3.8",
|
|
27
|
+
"@choice-ui/shared": "0.0.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^18.3.12",
|
|
31
|
+
"@types/react-dom": "^18.3.1",
|
|
32
|
+
"react": "^18.3.1",
|
|
33
|
+
"react-dom": "^18.3.1",
|
|
34
|
+
"rimraf": "^6.0.1",
|
|
35
|
+
"tsup": "^8.5.0",
|
|
36
|
+
"typescript": "^5.5.3"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": ">=18.0.0",
|
|
40
|
+
"react-dom": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"build:watch": "tsup --watch",
|
|
45
|
+
"clean": "rimraf dist"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createContext, useContext } from "react"
|
|
2
|
+
|
|
3
|
+
// Radio 组件的 Context
|
|
4
|
+
export interface RadioContextType {
|
|
5
|
+
descriptionId: string
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
id: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const RadioContext = createContext<RadioContextType | null>(null)
|
|
11
|
+
|
|
12
|
+
export function useRadioContext() {
|
|
13
|
+
const context = useContext(RadioContext)
|
|
14
|
+
if (!context) {
|
|
15
|
+
throw new Error("Radio.Label must be used within a Radio component")
|
|
16
|
+
}
|
|
17
|
+
return context
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// RadioGroup 组件的 Context
|
|
21
|
+
export interface RadioGroupContextType {
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
name: string
|
|
24
|
+
onChange: (value: string) => void
|
|
25
|
+
readOnly?: boolean
|
|
26
|
+
value: string
|
|
27
|
+
variant?: "default" | "accent" | "outline"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const RadioGroupContext = createContext<RadioGroupContextType | null>(null)
|
|
31
|
+
|
|
32
|
+
export function useRadioGroupContext() {
|
|
33
|
+
const context = useContext(RadioGroupContext)
|
|
34
|
+
if (!context) {
|
|
35
|
+
throw new Error("RadioGroupItem must be used within a RadioGroup")
|
|
36
|
+
}
|
|
37
|
+
return context
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Radio } from "./radio"
|
|
2
|
+
export { RadioGroup } from "./radio-group"
|
|
3
|
+
export { RadioLabel } from "./radio-label"
|
|
4
|
+
export { useRadioContext, useRadioGroupContext } from "./context"
|
|
5
|
+
export type { RadioProps } from "./radio"
|
|
6
|
+
export type { RadioGroupProps } from "./radio-group"
|
|
7
|
+
export type { RadioLabelProps } from "./radio-label"
|
|
8
|
+
export type { RadioContextType, RadioGroupContextType } from "./context"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { tcx } from "@choice-ui/shared"
|
|
2
|
+
import { forwardRef, HTMLProps, memo, ReactNode, useId, useMemo } from "react"
|
|
3
|
+
import { useEventCallback } from "usehooks-ts"
|
|
4
|
+
import { RadioGroupContext, useRadioGroupContext } from "./context"
|
|
5
|
+
import { Radio, RadioProps } from "./radio"
|
|
6
|
+
|
|
7
|
+
export interface RadioGroupProps extends Omit<HTMLProps<HTMLDivElement>, "value" | "onChange"> {
|
|
8
|
+
children?: ReactNode
|
|
9
|
+
onChange: (value: string) => void
|
|
10
|
+
options?: {
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
label: string
|
|
13
|
+
value: string
|
|
14
|
+
}[]
|
|
15
|
+
readOnly?: boolean
|
|
16
|
+
value: string
|
|
17
|
+
variant?: "default" | "accent" | "outline"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RadioGroupItemProps = Omit<RadioProps, "value" | "onChange"> & {
|
|
21
|
+
children: ReactNode
|
|
22
|
+
value: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RadioGroupItem = memo(
|
|
26
|
+
forwardRef<HTMLInputElement, RadioGroupItemProps>(function RadioGroupItem(props, ref) {
|
|
27
|
+
const { value, children, className, disabled, ...rest } = props
|
|
28
|
+
const {
|
|
29
|
+
name,
|
|
30
|
+
value: selectedValue,
|
|
31
|
+
onChange,
|
|
32
|
+
variant,
|
|
33
|
+
readOnly: contextReadonly,
|
|
34
|
+
} = useRadioGroupContext()
|
|
35
|
+
const isChecked = selectedValue === value
|
|
36
|
+
|
|
37
|
+
const handleChange = useEventCallback(() => {
|
|
38
|
+
if (contextReadonly) return
|
|
39
|
+
onChange(value)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Radio
|
|
44
|
+
name={name}
|
|
45
|
+
value={isChecked}
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
readOnly={contextReadonly}
|
|
48
|
+
variant={variant}
|
|
49
|
+
onChange={handleChange}
|
|
50
|
+
className={className}
|
|
51
|
+
{...rest}
|
|
52
|
+
ref={ref}
|
|
53
|
+
>
|
|
54
|
+
<Radio.Label>{children}</Radio.Label>
|
|
55
|
+
</Radio>
|
|
56
|
+
)
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
RadioGroupItem.displayName = "RadioGroup.Item"
|
|
61
|
+
|
|
62
|
+
const RadioGroupBase = forwardRef<HTMLDivElement, RadioGroupProps>(function RadioGroup(props, ref) {
|
|
63
|
+
const {
|
|
64
|
+
className,
|
|
65
|
+
options,
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
disabled,
|
|
69
|
+
readOnly = false,
|
|
70
|
+
variant = "default",
|
|
71
|
+
children,
|
|
72
|
+
...rest
|
|
73
|
+
} = props
|
|
74
|
+
const id = useId()
|
|
75
|
+
|
|
76
|
+
const handleChange = useEventCallback((newValue: string) => {
|
|
77
|
+
if (readOnly) return
|
|
78
|
+
onChange(newValue)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const contextValue = useMemo(
|
|
82
|
+
() => ({
|
|
83
|
+
name: id,
|
|
84
|
+
value,
|
|
85
|
+
onChange: handleChange,
|
|
86
|
+
disabled,
|
|
87
|
+
readOnly,
|
|
88
|
+
variant,
|
|
89
|
+
}),
|
|
90
|
+
[id, value, handleChange, disabled, readOnly, variant],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// 渲染基于选项的单选按钮
|
|
94
|
+
const renderOptionsRadios = useMemo(() => {
|
|
95
|
+
if (!options) return null
|
|
96
|
+
|
|
97
|
+
return options.map((option) => (
|
|
98
|
+
<RadioGroupItem
|
|
99
|
+
key={option.value}
|
|
100
|
+
value={option.value}
|
|
101
|
+
disabled={option.disabled || disabled}
|
|
102
|
+
variant={variant}
|
|
103
|
+
>
|
|
104
|
+
{option.label}
|
|
105
|
+
</RadioGroupItem>
|
|
106
|
+
))
|
|
107
|
+
}, [disabled, options, variant])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<RadioGroupContext.Provider value={contextValue}>
|
|
111
|
+
<div
|
|
112
|
+
className={tcx("flex flex-col gap-2", className)}
|
|
113
|
+
ref={ref}
|
|
114
|
+
role="radiogroup"
|
|
115
|
+
aria-labelledby={props["aria-labelledby"]}
|
|
116
|
+
aria-label={props["aria-label"]}
|
|
117
|
+
{...rest}
|
|
118
|
+
>
|
|
119
|
+
{options ? renderOptionsRadios : children}
|
|
120
|
+
</div>
|
|
121
|
+
</RadioGroupContext.Provider>
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// 使用 memo 包装组件以避免不必要的重渲染
|
|
126
|
+
const MemoizedRadioGroup = memo(RadioGroupBase) as unknown as RadioGroupType
|
|
127
|
+
|
|
128
|
+
interface RadioGroupType {
|
|
129
|
+
(props: RadioGroupProps & { ref?: React.Ref<HTMLDivElement> }): JSX.Element
|
|
130
|
+
Item: typeof RadioGroupItem
|
|
131
|
+
displayName?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const RadioGroup = MemoizedRadioGroup
|
|
135
|
+
|
|
136
|
+
RadioGroup.Item = RadioGroupItem
|
|
137
|
+
RadioGroup.displayName = "RadioGroup"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { tcx } from "@choice-ui/shared"
|
|
2
|
+
import { forwardRef, HTMLProps, memo, ReactNode } from "react"
|
|
3
|
+
import { useRadioContext } from "./context"
|
|
4
|
+
import { radioTv } from "./tv"
|
|
5
|
+
|
|
6
|
+
export interface RadioLabelProps extends Omit<
|
|
7
|
+
HTMLProps<HTMLLabelElement>,
|
|
8
|
+
"htmlFor" | "id" | "disabled"
|
|
9
|
+
> {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const RadioLabel = memo(
|
|
14
|
+
forwardRef<HTMLLabelElement, RadioLabelProps>(function RadioLabel(props, ref) {
|
|
15
|
+
const { children, className, ...rest } = props
|
|
16
|
+
const { id, descriptionId, disabled } = useRadioContext()
|
|
17
|
+
const styles = radioTv({ disabled })
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<label
|
|
21
|
+
ref={ref}
|
|
22
|
+
id={descriptionId}
|
|
23
|
+
htmlFor={id}
|
|
24
|
+
className={tcx(styles.label(), className)}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</label>
|
|
29
|
+
)
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
RadioLabel.displayName = "Radio.Label"
|
package/src/radio.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { tcx } from "@choice-ui/shared"
|
|
2
|
+
import { Dot } from "@choiceform/icons-react"
|
|
3
|
+
import { forwardRef, HTMLProps, memo, ReactNode, useId } from "react"
|
|
4
|
+
import { useEventCallback } from "usehooks-ts"
|
|
5
|
+
import { RadioContext } from "./context"
|
|
6
|
+
import { RadioLabel } from "./radio-label"
|
|
7
|
+
import { radioTv } from "./tv"
|
|
8
|
+
|
|
9
|
+
export interface RadioProps extends Omit<HTMLProps<HTMLInputElement>, "value" | "onChange"> {
|
|
10
|
+
children?: ReactNode
|
|
11
|
+
className?: string
|
|
12
|
+
focused?: boolean
|
|
13
|
+
onChange: (value: boolean) => void
|
|
14
|
+
readOnly?: boolean
|
|
15
|
+
value: boolean
|
|
16
|
+
variant?: "default" | "accent" | "outline"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RadioBase = forwardRef<HTMLInputElement, RadioProps>(function Radio(props, ref) {
|
|
20
|
+
const {
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
disabled,
|
|
24
|
+
readOnly = false,
|
|
25
|
+
name,
|
|
26
|
+
variant = "default",
|
|
27
|
+
className,
|
|
28
|
+
focused,
|
|
29
|
+
children,
|
|
30
|
+
"aria-label": ariaLabel,
|
|
31
|
+
"aria-describedby": ariaDescribedby,
|
|
32
|
+
onKeyDown,
|
|
33
|
+
...rest
|
|
34
|
+
} = props
|
|
35
|
+
const id = useId()
|
|
36
|
+
const descriptionId = useId()
|
|
37
|
+
|
|
38
|
+
const styles = radioTv({
|
|
39
|
+
type: "radio",
|
|
40
|
+
variant,
|
|
41
|
+
disabled,
|
|
42
|
+
checked: value,
|
|
43
|
+
focused,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const handleChange = useEventCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
47
|
+
if (readOnly) return
|
|
48
|
+
onChange(e.target.checked)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const handleKeyDown = useEventCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
52
|
+
if (readOnly) return
|
|
53
|
+
if (e.key === " " || e.key === "Enter") {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
onChange(!value)
|
|
56
|
+
}
|
|
57
|
+
onKeyDown?.(e)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// 自动将字符串类型的 children 包装成 RadioLabel
|
|
61
|
+
const renderChildren = () => {
|
|
62
|
+
if (typeof children === "string" || typeof children === "number") {
|
|
63
|
+
return <RadioLabel>{children}</RadioLabel>
|
|
64
|
+
}
|
|
65
|
+
return children
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<RadioContext.Provider value={{ id, descriptionId, disabled }}>
|
|
70
|
+
<div className={tcx(styles.root(), className)}>
|
|
71
|
+
<div className="pointer-events-none relative">
|
|
72
|
+
<input
|
|
73
|
+
ref={ref}
|
|
74
|
+
className={styles.input()}
|
|
75
|
+
type="radio"
|
|
76
|
+
id={id}
|
|
77
|
+
name={name}
|
|
78
|
+
checked={value}
|
|
79
|
+
disabled={disabled || readOnly}
|
|
80
|
+
onChange={handleChange}
|
|
81
|
+
aria-label={ariaLabel}
|
|
82
|
+
aria-describedby={ariaDescribedby || descriptionId}
|
|
83
|
+
aria-checked={value}
|
|
84
|
+
aria-disabled={disabled || readOnly}
|
|
85
|
+
role="radio"
|
|
86
|
+
onKeyDown={handleKeyDown}
|
|
87
|
+
{...rest}
|
|
88
|
+
/>
|
|
89
|
+
<div className={styles.box()}>{value && <Dot />}</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{renderChildren()}
|
|
93
|
+
</div>
|
|
94
|
+
</RadioContext.Provider>
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const MemoizedRadio = memo(RadioBase) as unknown as RadioType
|
|
99
|
+
|
|
100
|
+
interface RadioType {
|
|
101
|
+
(props: RadioProps & { ref?: React.Ref<HTMLInputElement> }): JSX.Element
|
|
102
|
+
Label: typeof RadioLabel
|
|
103
|
+
displayName?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const Radio = MemoizedRadio as RadioType
|
|
107
|
+
|
|
108
|
+
Radio.Label = RadioLabel
|
|
109
|
+
Radio.displayName = "Radio"
|