@tamagui/radio-group 1.4.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.
@@ -0,0 +1,482 @@
1
+ // forked from Radix UI
2
+ // https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/src/RadioGroup.tsx
3
+
4
+ import { usePrevious } from '@radix-ui/react-use-previous'
5
+ import {
6
+ GetProps,
7
+ composeEventHandlers,
8
+ getVariableValue,
9
+ isWeb,
10
+ styled,
11
+ useComposedRefs,
12
+ withStaticProperties,
13
+ } from '@tamagui/core'
14
+ import { Scope, createContextScope } from '@tamagui/create-context'
15
+ import { registerFocusable } from '@tamagui/focusable'
16
+ import { getSize, stepTokenUpOrDown } from '@tamagui/get-size'
17
+ import { useLabelContext } from '@tamagui/label'
18
+ import { ThemeableStack } from '@tamagui/stacks'
19
+ import { useControllableState } from '@tamagui/use-controllable-state'
20
+ import * as React from 'react'
21
+ import { View } from 'react-native'
22
+
23
+ const RADIO_GROUP_NAME = 'RadioGroup'
24
+
25
+ const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
26
+
27
+ const [createRadioGroupContext, createRadioGroupScope] =
28
+ createContextScope(RADIO_GROUP_NAME)
29
+ type RadioGroupContextValue = {
30
+ value?: string
31
+ disabled?: boolean
32
+ required?: boolean
33
+ onChange?: (value: string) => void
34
+ name?: string
35
+ native?: boolean
36
+ accentColor?: string
37
+ }
38
+ const [RadioGroupProvider, useRadioGroupContext] =
39
+ createRadioGroupContext<RadioGroupContextValue>(RADIO_GROUP_NAME)
40
+
41
+ const getState = (checked: boolean) => {
42
+ return checked ? 'checked' : 'unchecked'
43
+ }
44
+
45
+ /* -------------------------------------------------------------------------
46
+ * RadioIndicator
47
+ * ------------------------------------------------------------------------ */
48
+
49
+ const RADIO_GROUP_INDICATOR_NAME = 'RadioGroupIndicator'
50
+
51
+ const RadioIndicatorFrame = styled(ThemeableStack, {
52
+ name: RADIO_GROUP_INDICATOR_NAME,
53
+ pointerEvents: 'none',
54
+
55
+ variants: {
56
+ unstyled: {
57
+ false: {
58
+ w: '60%',
59
+ h: '60%',
60
+ br: 1000,
61
+ backgroundColor: '$color',
62
+ },
63
+ },
64
+ } as const,
65
+
66
+ defaultVariants: {
67
+ unstyled: false,
68
+ },
69
+ })
70
+
71
+ type RadioIndicatorProps = GetProps<typeof RadioIndicatorFrame> & {
72
+ forceMount?: boolean
73
+ unstyled?: boolean
74
+ }
75
+
76
+ type RadioIndicatorElement = TamaguiElement
77
+
78
+ const RadioIndicator = RadioIndicatorFrame.extractable(
79
+ React.forwardRef<RadioIndicatorElement, RadioIndicatorProps>(
80
+ (props: ScopedRadioGroupItemProps<RadioIndicatorProps>, forwardedRef) => {
81
+ const { __scopeRadioGroupItem, forceMount, disabled, ...indicatorProps } = props
82
+ const { checked } = useRadioGroupItemContext(
83
+ RADIO_GROUP_INDICATOR_NAME,
84
+ __scopeRadioGroupItem
85
+ )
86
+
87
+ if (forceMount || checked) {
88
+ return (
89
+ <RadioIndicatorFrame
90
+ theme="active"
91
+ data-state={getState(checked)}
92
+ data-disabled={disabled ? '' : undefined}
93
+ {...indicatorProps}
94
+ ref={forwardedRef}
95
+ />
96
+ )
97
+ }
98
+
99
+ return null
100
+ }
101
+ )
102
+ )
103
+
104
+ RadioIndicator.displayName = RADIO_GROUP_INDICATOR_NAME
105
+
106
+ /* -------------------------------------------------------------------------
107
+ * RadioGroupItem
108
+ * ------------------------------------------------------------------------ */
109
+
110
+ const RADIO_GROUP_ITEM_NAME = 'RadioGroupItem'
111
+
112
+ type RadioGroupItemContextValue = {
113
+ checked: boolean
114
+ disabled?: boolean
115
+ }
116
+
117
+ const [RadioGroupItemProvider, useRadioGroupItemContext] =
118
+ createRadioGroupContext<RadioGroupItemContextValue>(RADIO_GROUP_NAME)
119
+
120
+ const RadioGroupItemFrame = styled(ThemeableStack, {
121
+ name: RADIO_GROUP_ITEM_NAME,
122
+ tag: 'button',
123
+ debug: 'verbose',
124
+
125
+ variants: {
126
+ unstyled: {
127
+ false: {
128
+ borderRadius: 9999,
129
+ backgroundColor: '$background',
130
+ alignItems: 'center',
131
+ justifyContent: 'center',
132
+ borderWidth: 2,
133
+ borderColor: 'transparent',
134
+
135
+ focusStyle: {
136
+ borderColor: '$borderColorFocus',
137
+ },
138
+ },
139
+ },
140
+
141
+ size: {
142
+ '...size': (value, { props }) => {
143
+ const size = Math.floor(
144
+ getVariableValue(getSize(value)) * (props['scaleSize'] ?? 0.5)
145
+ )
146
+ return {
147
+ width: size,
148
+ height: size,
149
+ }
150
+ },
151
+ },
152
+ } as const,
153
+
154
+ defaultVariants: {
155
+ size: '$true',
156
+ unstyled: false,
157
+ },
158
+ })
159
+
160
+ type RadioGroupItemProps = GetProps<typeof RadioGroupItemFrame> & {
161
+ value: string
162
+ id?: string
163
+ labelledBy?: string
164
+ disabled?: boolean
165
+ }
166
+
167
+ type RadioGroupItemElement = HTMLButtonElement
168
+
169
+ type ScopedRadioGroupItemProps<P> = P & {
170
+ __scopeRadioGroupItem?: Scope
171
+ }
172
+
173
+ const RadioGroupItem = RadioGroupItemFrame.extractable(
174
+ React.forwardRef<RadioGroupItemElement, RadioGroupItemProps>(
175
+ (props: ScopedProps<RadioGroupItemProps>, forwardedRef) => {
176
+ const {
177
+ __scopeRadioGroup,
178
+ value,
179
+ labelledBy: ariaLabelledby,
180
+ disabled: itemDisabled,
181
+ ...itemProps
182
+ } = props
183
+ const {
184
+ value: groupValue,
185
+ disabled,
186
+ required,
187
+ onChange,
188
+ name,
189
+ native,
190
+ accentColor,
191
+ } = useRadioGroupContext(RADIO_GROUP_ITEM_NAME, __scopeRadioGroup)
192
+ const [button, setButton] = React.useState<HTMLButtonElement | null>(null)
193
+ const hasConsumerStoppedPropagationRef = React.useRef(false)
194
+ const ref = React.useRef<HTMLButtonElement>(null)
195
+ const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node), ref)
196
+ const isArrowKeyPressedRef = React.useRef(false)
197
+
198
+ const isFormControl = isWeb
199
+ ? button
200
+ ? Boolean(button.closest('form'))
201
+ : true
202
+ : false
203
+
204
+ const checked = groupValue === value
205
+
206
+ const labelId = useLabelContext(button)
207
+ const labelledBy = ariaLabelledby || labelId
208
+
209
+ React.useEffect(() => {
210
+ if (isWeb) {
211
+ const handleKeyDown = (event: KeyboardEvent) => {
212
+ if (ARROW_KEYS.includes(event.key)) {
213
+ isArrowKeyPressedRef.current = true
214
+ }
215
+ }
216
+ const handleKeyUp = () => (isArrowKeyPressedRef.current = false)
217
+ document.addEventListener('keydown', handleKeyDown)
218
+ document.addEventListener('keyup', handleKeyUp)
219
+ return () => {
220
+ document.removeEventListener('keydown', handleKeyDown)
221
+ document.removeEventListener('keyup', handleKeyUp)
222
+ }
223
+ }
224
+ }, [])
225
+
226
+ if (process.env.TAMAGUI_TARGET === 'native') {
227
+ // eslint-disable-next-line react-hooks/rules-of-hooks
228
+ React.useEffect(() => {
229
+ if (!props.id) return
230
+ return registerFocusable(props.id, {
231
+ focusAndSelect: () => {
232
+ onChange?.(value)
233
+ },
234
+ focus: () => {},
235
+ })
236
+ }, [props.id, value])
237
+ }
238
+
239
+ const isDisabled = disabled || itemDisabled
240
+
241
+ return (
242
+ <RadioGroupItemProvider checked={checked} scope={__scopeRadioGroup}>
243
+ {isWeb && native ? (
244
+ <BubbleInput
245
+ control={button}
246
+ bubbles={!hasConsumerStoppedPropagationRef.current}
247
+ name={name}
248
+ value={value}
249
+ checked={checked}
250
+ required={required}
251
+ disabled={isDisabled}
252
+ id={props.id}
253
+ accentColor={accentColor}
254
+ />
255
+ ) : (
256
+ <>
257
+ <RadioGroupItemFrame
258
+ // theme={checked ? 'active' : undefined}
259
+ data-state={getState(checked)}
260
+ data-disabled={isDisabled ? '' : undefined}
261
+ role="radio"
262
+ aria-labelledby={labelledBy}
263
+ aria-checked={checked}
264
+ aria-required={required}
265
+ disabled={isDisabled}
266
+ ref={composedRefs}
267
+ {...(isWeb && {
268
+ type: 'button',
269
+ value: value,
270
+ })}
271
+ // allow them to override all but the handlers that already compose:
272
+ {...itemProps}
273
+ onPress={composeEventHandlers(props.onPress as any, (event) => {
274
+ if (!checked) {
275
+ onChange?.(value)
276
+ }
277
+
278
+ if (isFormControl) {
279
+ hasConsumerStoppedPropagationRef.current =
280
+ event.isPropagationStopped()
281
+ // if radio is in a form, stop propagation from the button so that we only propagate
282
+ // one click event (from the input). We propagate changes from an input so that native
283
+ // form validation works and form events reflect radio updates.
284
+ if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation()
285
+ }
286
+ })}
287
+ {...(isWeb && {
288
+ onKeyDown: composeEventHandlers(
289
+ (props as React.HTMLProps<HTMLButtonElement>).onKeyDown,
290
+ (event) => {
291
+ // According to WAI ARIA, Checkboxes don't activate on enter keypress
292
+ if (event.key === 'Enter') event.preventDefault()
293
+ }
294
+ ),
295
+ onFocus: composeEventHandlers(itemProps.onFocus, () => {
296
+ /**
297
+ * Our `RovingFocusGroup` will focus the radio when navigating with arrow keys
298
+ * and we need to "check" it in that case. We click it to "check" it (instead
299
+ * of updating `context.value`) so that the radio change event fires.
300
+ */
301
+ if (isArrowKeyPressedRef.current)
302
+ (ref.current as HTMLButtonElement)?.click()
303
+ }),
304
+ })}
305
+ />
306
+ {isFormControl && (
307
+ <BubbleInput
308
+ isHidden
309
+ control={button}
310
+ bubbles={!hasConsumerStoppedPropagationRef.current}
311
+ name={name}
312
+ value={value}
313
+ checked={checked}
314
+ required={required}
315
+ disabled={isDisabled}
316
+ />
317
+ )}
318
+ </>
319
+ )}
320
+ </RadioGroupItemProvider>
321
+ )
322
+ }
323
+ )
324
+ )
325
+
326
+ /* -------------------------------------------------------------------------
327
+ * BubbleInput
328
+ * ------------------------------------------------------------------------ */
329
+
330
+ interface BubbleInputProps extends Omit<React.HTMLProps<HTMLInputElement>, 'checked'> {
331
+ checked: boolean
332
+ control: HTMLElement | null
333
+ bubbles: boolean
334
+ isHidden?: boolean
335
+ accentColor?: string
336
+ }
337
+
338
+ const BubbleInput = (props: BubbleInputProps) => {
339
+ const { checked, bubbles = true, control, isHidden, accentColor, ...inputProps } = props
340
+ const ref = React.useRef<HTMLInputElement>(null)
341
+ const prevChecked = usePrevious(checked)
342
+
343
+ // Bubble checked change to parents (e.g form change event)
344
+ React.useEffect(() => {
345
+ const input = ref.current!
346
+ const inputProto = window.HTMLInputElement.prototype
347
+ const descriptor = Object.getOwnPropertyDescriptor(
348
+ inputProto,
349
+ 'checked'
350
+ ) as PropertyDescriptor
351
+ const setChecked = descriptor.set
352
+ if (prevChecked !== checked && setChecked) {
353
+ const event = new Event('click', { bubbles })
354
+ setChecked.call(input, checked)
355
+ input.dispatchEvent(event)
356
+ }
357
+ }, [prevChecked, checked, bubbles])
358
+
359
+ return (
360
+ <input
361
+ type="radio"
362
+ defaultChecked={checked}
363
+ {...inputProps}
364
+ tabIndex={-1}
365
+ ref={ref}
366
+ aria-hidden={isHidden}
367
+ style={{
368
+ ...(isHidden
369
+ ? {
370
+ // ...controlSize,
371
+ position: 'absolute',
372
+ pointerEvents: 'none',
373
+ opacity: 0,
374
+ margin: 0,
375
+ }
376
+ : {
377
+ appearance: 'auto',
378
+ accentColor,
379
+ }),
380
+
381
+ ...props.style,
382
+ }}
383
+ />
384
+ )
385
+ }
386
+
387
+ /* -------------------------------------------------------------------------
388
+ * RadioGroup
389
+ * ----------------------------------------------------------------------- */
390
+
391
+ type ScopedProps<P> = P & { __scopeRadioGroup?: Scope }
392
+
393
+ type TamaguiElement = HTMLElement | View
394
+
395
+ type RadioGroupElement = TamaguiElement
396
+
397
+ const RadioGroupFrame = styled(ThemeableStack, {
398
+ name: RADIO_GROUP_NAME,
399
+
400
+ variants: {
401
+ orientation: {
402
+ horizontal: {
403
+ flexDirection: 'row',
404
+ spaceDirection: 'horizontal',
405
+ },
406
+ vertical: {
407
+ flexDirection: 'column',
408
+ spaceDirection: 'vertical',
409
+ },
410
+ },
411
+ } as const,
412
+ })
413
+
414
+ type RadioGroupProps = GetProps<typeof RadioGroupFrame> & {
415
+ value?: string
416
+ defaultValue?: string
417
+ onValueChange?: (value: string) => void
418
+ required?: boolean
419
+ disabled?: boolean
420
+ name?: string
421
+ native?: boolean
422
+ accentColor?: string
423
+ }
424
+
425
+ const RadioGroup = withStaticProperties(
426
+ RadioGroupFrame.extractable(
427
+ React.forwardRef<RadioGroupElement, RadioGroupProps>(
428
+ (props: ScopedProps<RadioGroupProps>, forwardedRef) => {
429
+ const {
430
+ __scopeRadioGroup,
431
+ value: valueProp,
432
+ defaultValue,
433
+ onValueChange,
434
+ disabled = false,
435
+ required = false,
436
+ name,
437
+ orientation,
438
+ native,
439
+ accentColor,
440
+ ...radioGroupProps
441
+ } = props
442
+ const [value, setValue] = useControllableState({
443
+ prop: valueProp,
444
+ defaultProp: defaultValue!,
445
+ onChange: onValueChange,
446
+ })
447
+
448
+ return (
449
+ <RadioGroupProvider
450
+ scope={__scopeRadioGroup}
451
+ value={value}
452
+ required={required}
453
+ onChange={setValue}
454
+ disabled={disabled}
455
+ name={name}
456
+ native={native}
457
+ accentColor={accentColor}
458
+ >
459
+ <RadioGroupFrame
460
+ aria-valuetext={value}
461
+ role="radiogroup"
462
+ aria-orientation={orientation}
463
+ ref={forwardedRef}
464
+ orientation={orientation}
465
+ data-disabled={disabled ? '' : undefined}
466
+ {...radioGroupProps}
467
+ />
468
+ </RadioGroupProvider>
469
+ )
470
+ }
471
+ )
472
+ ),
473
+ {
474
+ Indicator: RadioIndicator,
475
+ Item: RadioGroupItem,
476
+ }
477
+ )
478
+
479
+ RadioGroup.displayName = RADIO_GROUP_NAME
480
+
481
+ export { createRadioGroupScope, RadioGroup }
482
+ export type { RadioGroupProps }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './RadioGroup'