@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/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"