@tamagui/switch 1.57.8 → 1.58.2

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/src/Switch.tsx CHANGED
@@ -1,58 +1,12 @@
1
- // via radix
2
- // https://github.com/radix-ui/primitives/blob/main/packages/react/switch/src/Switch.tsx
3
-
4
- import { useComposedRefs } from '@tamagui/compose-refs'
5
- import {
6
- GetProps,
7
- GetRef,
8
- NativeValue,
9
- SizeTokens,
10
- getVariableValue,
11
- isWeb,
12
- styled,
13
- withStaticProperties,
14
- } from '@tamagui/core'
15
- import { ScopedProps, createContextScope } from '@tamagui/create-context'
16
- import { registerFocusable } from '@tamagui/focusable'
1
+ import { SizeTokens, getVariableValue, styled } from '@tamagui/core'
17
2
  import { getSize } from '@tamagui/get-token'
18
- import { useLabelContext } from '@tamagui/label'
19
3
  import { ThemeableStack, XStack } from '@tamagui/stacks'
20
- import { useControllableState } from '@tamagui/use-controllable-state'
21
- import { usePrevious } from '@tamagui/use-previous'
22
- import * as React from 'react'
23
- import {
24
- Switch as NativeSwitch,
25
- SwitchProps as NativeSwitchProps,
26
- Platform,
27
- View,
28
- } from 'react-native'
29
-
30
- const SWITCH_NAME = 'Switch'
31
-
32
- // TODO make customizable
33
- const getSwitchHeight = (val: SizeTokens) =>
34
- Math.round(getVariableValue(getSize(val)) * 0.65)
35
- const getSwitchWidth = (val: SizeTokens) => getSwitchHeight(val) * 2
36
-
37
- const scopeContexts = createContextScope(SWITCH_NAME)
38
- const [createSwitchContext] = scopeContexts
39
- export const createSwitchScope = scopeContexts[1]
40
4
 
41
- const [SwitchProvider, useSwitchContext] = createSwitchContext<{
42
- checked: boolean
43
- disabled?: boolean
44
- size: SizeTokens
45
- unstyled?: boolean
46
- }>(SWITCH_NAME)
5
+ import { SwitchContext, createSwitch } from './createSwitch'
47
6
 
48
- /* -------------------------------------------------------------------------------------------------
49
- * SwitchThumb
50
- * -----------------------------------------------------------------------------------------------*/
51
-
52
- const THUMB_NAME = 'SwitchThumb'
53
-
54
- export const SwitchThumbFrame = styled(ThemeableStack, {
7
+ export const SwitchThumb = styled(ThemeableStack, {
55
8
  name: 'SwitchThumb',
9
+ context: SwitchContext,
56
10
 
57
11
  variants: {
58
12
  unstyled: {
@@ -79,46 +33,15 @@ export const SwitchThumbFrame = styled(ThemeableStack, {
79
33
  },
80
34
  })
81
35
 
82
- export type SwitchThumbProps = GetProps<typeof SwitchThumbFrame>
83
-
84
- export const SwitchThumb = SwitchThumbFrame.extractable(
85
- React.forwardRef<GetRef<typeof SwitchThumbFrame>, SwitchThumbProps>(
86
- function SwitchThumb(props: ScopedProps<SwitchThumbProps, 'Switch'>, forwardedRef) {
87
- const { __scopeSwitch, size: sizeProp, ...thumbProps } = props
88
- const {
89
- size: sizeContext,
90
- disabled,
91
- checked,
92
- unstyled,
93
- } = useSwitchContext(THUMB_NAME, __scopeSwitch)
94
- const size = sizeProp ?? sizeContext
95
- return (
96
- <SwitchThumbFrame
97
- unstyled={unstyled}
98
- size={size}
99
- data-state={getState(checked)}
100
- data-disabled={disabled ? '' : undefined}
101
- x={
102
- checked
103
- ? getVariableValue(getSwitchWidth(size)) -
104
- getVariableValue(getSwitchHeight(size))
105
- : 0
106
- }
107
- {...thumbProps}
108
- ref={forwardedRef}
109
- />
110
- )
111
- }
112
- )
113
- )
36
+ const getSwitchHeight = (val: SizeTokens) =>
37
+ Math.round(getVariableValue(getSize(val)) * 0.65)
114
38
 
115
- /* -------------------------------------------------------------------------------------------------
116
- * Switch
117
- * -----------------------------------------------------------------------------------------------*/
39
+ const getSwitchWidth = (val: SizeTokens) => getSwitchHeight(val) * 2
118
40
 
119
41
  export const SwitchFrame = styled(XStack, {
120
- name: SWITCH_NAME,
42
+ name: 'Switch',
121
43
  tag: 'button',
44
+ context: SwitchContext,
122
45
 
123
46
  variants: {
124
47
  unstyled: {
@@ -138,6 +61,14 @@ export const SwitchFrame = styled(XStack, {
138
61
  },
139
62
  },
140
63
 
64
+ checked: {
65
+ true: {},
66
+ },
67
+
68
+ frameWidth: {
69
+ ':number': () => null,
70
+ },
71
+
141
72
  size: {
142
73
  '...size': (val) => {
143
74
  const height = getSwitchHeight(val) + 4
@@ -156,203 +87,8 @@ export const SwitchFrame = styled(XStack, {
156
87
  },
157
88
  })
158
89
 
159
- type SwitchButtonProps = GetProps<typeof SwitchFrame>
160
-
161
- export type SwitchProps = SwitchButtonProps & {
162
- labeledBy?: string
163
- name?: string
164
- value?: string
165
- checked?: boolean
166
- defaultChecked?: boolean
167
- required?: boolean
168
- native?: NativeValue<'mobile' | 'ios' | 'android'>
169
- nativeProps?: NativeSwitchProps
170
- onCheckedChange?(checked: boolean): void
171
- }
172
-
173
- const SwitchComponent = SwitchFrame.extractable(
174
- React.forwardRef<HTMLButtonElement | View, SwitchProps>(
175
- (props: ScopedProps<SwitchProps, 'Switch'>, forwardedRef) => {
176
- const {
177
- __scopeSwitch,
178
- labeledBy: ariaLabelledby,
179
- name,
180
- checked: checkedProp,
181
- defaultChecked,
182
- required,
183
- disabled,
184
- value = 'on',
185
- onCheckedChange,
186
- size = '$true',
187
- unstyled = false,
188
- native: nativeProp,
189
- nativeProps,
190
- ...switchProps
191
- } = props
192
- const native = Array.isArray(nativeProp) ? nativeProp : [nativeProp]
193
- const shouldRenderMobileNative =
194
- (!isWeb && nativeProp === true) ||
195
- (!isWeb && native.includes('mobile')) ||
196
- (native.includes('android') && Platform.OS === 'android') ||
197
- (native.includes('ios') && Platform.OS === 'ios')
198
- if (shouldRenderMobileNative) {
199
- return (
200
- <NativeSwitch
201
- value={checkedProp}
202
- onValueChange={onCheckedChange}
203
- {...nativeProps}
204
- />
205
- )
206
- }
207
- const [button, setButton] = React.useState<HTMLButtonElement | null>(null)
208
- const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node as any))
209
- const labelId = useLabelContext(button)
210
- const labelledBy = ariaLabelledby || labelId
211
- const hasConsumerStoppedPropagationRef = React.useRef(false)
212
- // We set this to true by default so that events bubble to forms without JS (SSR)
213
- const isFormControl = isWeb
214
- ? button
215
- ? Boolean(button.closest('form'))
216
- : true
217
- : false
218
- const [checked = false, setChecked] = useControllableState({
219
- prop: checkedProp,
220
- defaultProp: defaultChecked || false,
221
- onChange: onCheckedChange,
222
- transition: true,
223
- })
224
-
225
- if (!isWeb) {
226
- // eslint-disable-next-line react-hooks/rules-of-hooks
227
- React.useEffect(() => {
228
- if (!props.id) return
229
- return registerFocusable(props.id, {
230
- focus: () => {
231
- setChecked((x) => !x)
232
- },
233
- })
234
- }, [props.id, setChecked])
235
- }
236
-
237
- return (
238
- <SwitchProvider
239
- scope={__scopeSwitch}
240
- checked={checked}
241
- disabled={disabled}
242
- size={size}
243
- unstyled={unstyled}
244
- >
245
- <SwitchFrame
246
- unstyled={unstyled}
247
- size={size}
248
- theme={checked ? 'active' : null}
249
- // @ts-ignore
250
- role="switch"
251
- aria-checked={checked}
252
- aria-labelledby={labelledBy}
253
- aria-required={required}
254
- data-state={getState(checked)}
255
- data-disabled={disabled ? '' : undefined}
256
- disabled={disabled}
257
- // @ts-ignore
258
- tabIndex={disabled ? undefined : 0}
259
- // @ts-ignore
260
- value={value}
261
- {...switchProps}
262
- ref={composedRefs}
263
- onPress={(event) => {
264
- props.onPress?.(event)
265
- setChecked((prevChecked) => !prevChecked)
266
- if (isWeb && isFormControl) {
267
- hasConsumerStoppedPropagationRef.current = event.isPropagationStopped()
268
- // if switch is in a form, stop propagation from the button so that we only propagate
269
- // one click event (from the input). We propagate changes from an input so that native
270
- // form validation works and form events reflect switch updates.
271
- if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation()
272
- }
273
- }}
274
- />
275
- {isWeb && isFormControl && (
276
- <BubbleInput
277
- control={button}
278
- bubbles={!hasConsumerStoppedPropagationRef.current}
279
- name={name}
280
- value={value}
281
- checked={checked}
282
- required={required}
283
- disabled={disabled}
284
- // We transform because the input is absolutely positioned but we have
285
- // rendered it **after** the button. This pulls it back to sit on top
286
- // of the button.
287
- style={{ transform: 'translateX(-100%)' }}
288
- />
289
- )}
290
- </SwitchProvider>
291
- )
292
- }
293
- ),
294
- {
295
- // because they may set a variant to be checked, we need to make it also pass checked down
296
- inlineProps: new Set(['checked']),
297
- }
298
- )
299
-
300
- export const Switch = withStaticProperties(SwitchComponent, {
90
+ export const Switch = createSwitch({
91
+ Frame: SwitchFrame,
301
92
  Thumb: SwitchThumb,
93
+ acceptsUnstyled: true,
302
94
  })
303
-
304
- /* ---------------------------------------------------------------------------------------------- */
305
-
306
- type InputProps = any //Radix.ComponentPropsWithoutRef<'input'>
307
- interface BubbleInputProps extends Omit<InputProps, 'checked'> {
308
- checked: boolean
309
- control: HTMLElement | null
310
- bubbles: boolean
311
- }
312
-
313
- // TODO make this native friendly
314
- const BubbleInput = (props: BubbleInputProps) => {
315
- const { control, checked, bubbles = true, ...inputProps } = props
316
- const ref = React.useRef<HTMLInputElement>(null)
317
- const prevChecked = usePrevious(checked)
318
- // const controlSize = useSize(control)
319
-
320
- // Bubble checked change to parents (e.g form change event)
321
- React.useEffect(() => {
322
- const input = ref.current!
323
- const inputProto = window.HTMLInputElement.prototype
324
- const descriptor = Object.getOwnPropertyDescriptor(
325
- inputProto,
326
- 'checked'
327
- ) as PropertyDescriptor
328
- const setChecked = descriptor.set
329
- if (prevChecked !== checked && setChecked) {
330
- const event = new Event('click', { bubbles })
331
- setChecked.call(input, checked)
332
- input.dispatchEvent(event)
333
- }
334
- }, [prevChecked, checked, bubbles])
335
-
336
- return (
337
- <input
338
- type="checkbox"
339
- aria-hidden
340
- defaultChecked={checked}
341
- {...inputProps}
342
- tabIndex={-1}
343
- ref={ref}
344
- style={{
345
- ...props.style,
346
- // ...controlSize,
347
- position: 'absolute',
348
- pointerEvents: 'none',
349
- opacity: 0,
350
- margin: 0,
351
- }}
352
- />
353
- )
354
- }
355
-
356
- function getState(checked: boolean) {
357
- return checked ? 'checked' : 'unchecked'
358
- }
@@ -0,0 +1,304 @@
1
+ import { useComposedRefs } from '@tamagui/compose-refs'
2
+ import {
3
+ NativeValue,
4
+ SizeTokens,
5
+ StackProps,
6
+ TamaguiComponentExpectingVariants,
7
+ TamaguiElement,
8
+ composeEventHandlers,
9
+ createStyledContext,
10
+ getVariableValue,
11
+ isWeb,
12
+ useProps,
13
+ withStaticProperties,
14
+ } from '@tamagui/core'
15
+ import { registerFocusable } from '@tamagui/focusable'
16
+ import { getSize } from '@tamagui/get-token'
17
+ import { useLabelContext } from '@tamagui/label'
18
+ import { useControllableState } from '@tamagui/use-controllable-state'
19
+ import { usePrevious } from '@tamagui/use-previous'
20
+ import * as React from 'react'
21
+ import {
22
+ Switch as NativeSwitch,
23
+ SwitchProps as NativeSwitchProps,
24
+ Platform,
25
+ } from 'react-native'
26
+
27
+ export const SwitchContext = createStyledContext<{
28
+ checked: boolean
29
+ disabled?: boolean
30
+ frameWidth: number
31
+ size?: SizeTokens
32
+ unstyled?: boolean
33
+ }>({
34
+ checked: false,
35
+ disabled: false,
36
+ size: undefined,
37
+ frameWidth: 60,
38
+ unstyled: false,
39
+ })
40
+
41
+ type SwitchSharedProps = {
42
+ size?: SizeTokens | number
43
+ unstyled?: boolean
44
+ }
45
+
46
+ type SwitchBaseProps = StackProps & SwitchSharedProps
47
+
48
+ export type SwitchExtraProps = {
49
+ labeledBy?: string
50
+ name?: string
51
+ value?: string
52
+ checked?: boolean
53
+ defaultChecked?: boolean
54
+ required?: boolean
55
+ native?: NativeValue<'mobile' | 'ios' | 'android'>
56
+ nativeProps?: NativeSwitchProps
57
+ onCheckedChange?(checked: boolean): void
58
+ }
59
+
60
+ type SwitchProps = SwitchBaseProps & SwitchExtraProps
61
+
62
+ export function createSwitch<
63
+ F extends TamaguiComponentExpectingVariants<
64
+ SwitchProps,
65
+ SwitchSharedProps & SwitchExtraProps
66
+ >,
67
+ T extends TamaguiComponentExpectingVariants<SwitchBaseProps, SwitchSharedProps>
68
+ >({ Frame, Thumb, acceptsUnstyled }: { Frame: F; Thumb: T; acceptsUnstyled?: boolean }) {
69
+ const SwitchThumb = Thumb.styleable(function SwitchThumb(props, forwardedRef) {
70
+ const { size: sizeProp, ...thumbProps } = props
71
+ const { disabled, checked, unstyled, frameWidth } = React.useContext(SwitchContext)
72
+ const [thumbWidth, setThumbWidth] = React.useState(0)
73
+ return (
74
+ // @ts-ignore
75
+ <Thumb
76
+ theme={unstyled === false && checked ? 'active' : null}
77
+ data-state={getState(checked)}
78
+ data-disabled={disabled ? '' : undefined}
79
+ x={checked ? frameWidth - thumbWidth : 0}
80
+ {...thumbProps}
81
+ // @ts-ignore
82
+ onLayout={composeEventHandlers(props.onLayout, (e) =>
83
+ // @ts-ignore
84
+ setThumbWidth(e.nativeEvent.layout.width)
85
+ )}
86
+ ref={forwardedRef}
87
+ />
88
+ )
89
+ })
90
+
91
+ const SwitchComponent = Frame.extractable(
92
+ React.forwardRef<TamaguiElement, SwitchProps>(function SwitchFrame(
93
+ propsIn,
94
+ forwardedRef
95
+ ) {
96
+ const styledContext = React.useContext(SwitchContext)
97
+ const props = useProps(propsIn)
98
+ const {
99
+ labeledBy: ariaLabelledby,
100
+ name,
101
+ checked: checkedProp,
102
+ defaultChecked,
103
+ required,
104
+ disabled,
105
+ value = 'on',
106
+ onCheckedChange,
107
+ size = styledContext.size ?? '$true',
108
+ unstyled = styledContext.unstyled ?? false,
109
+ native: nativeProp,
110
+ nativeProps,
111
+ ...switchProps
112
+ } = props
113
+
114
+ const leftBorderWidth = (() => {
115
+ let _: any = undefined
116
+ for (const key in switchProps) {
117
+ if (key === 'borderWidth' || key === 'borderLeftWidth') {
118
+ _ = switchProps[key]
119
+ }
120
+ }
121
+ if (acceptsUnstyled && _ === undefined && unstyled === false) {
122
+ _ = 2 // default we use for styled
123
+ }
124
+ if (typeof _ === 'string') {
125
+ _ = getVariableValue(getSize(_))
126
+ }
127
+ return +_
128
+ })()
129
+
130
+ const native = Array.isArray(nativeProp) ? nativeProp : [nativeProp]
131
+
132
+ const shouldRenderMobileNative =
133
+ (!isWeb && nativeProp === true) ||
134
+ (!isWeb && native.includes('mobile')) ||
135
+ (native.includes('android') && Platform.OS === 'android') ||
136
+ (native.includes('ios') && Platform.OS === 'ios')
137
+
138
+ const [button, setButton] = React.useState<HTMLButtonElement | null>(null)
139
+ const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node as any))
140
+ const labelId = useLabelContext(button)
141
+ const labelledBy = ariaLabelledby || labelId
142
+ const hasConsumerStoppedPropagationRef = React.useRef(false)
143
+ // We set this to true by default so that events bubble to forms without JS (SSR)
144
+ const isFormControl = isWeb
145
+ ? button
146
+ ? Boolean(button.closest('form'))
147
+ : true
148
+ : false
149
+
150
+ // just guess some value
151
+ const [frameWidth, setFrameWidth] = React.useState(60)
152
+
153
+ const [checked = false, setChecked] = useControllableState({
154
+ prop: checkedProp,
155
+ defaultProp: defaultChecked || false,
156
+ onChange: onCheckedChange,
157
+ transition: true,
158
+ })
159
+
160
+ if (shouldRenderMobileNative) {
161
+ return (
162
+ <NativeSwitch
163
+ value={checkedProp}
164
+ onValueChange={onCheckedChange}
165
+ {...nativeProps}
166
+ />
167
+ )
168
+ }
169
+
170
+ if (!isWeb) {
171
+ // eslint-disable-next-line react-hooks/rules-of-hooks
172
+ React.useEffect(() => {
173
+ if (!props.id) return
174
+ return registerFocusable(props.id, {
175
+ focus: () => {
176
+ setChecked((x) => !x)
177
+ },
178
+ })
179
+ }, [props.id, setChecked])
180
+ }
181
+
182
+ return (
183
+ <>
184
+ {/* @ts-ignore */}
185
+ <Frame
186
+ unstyled={unstyled}
187
+ size={size}
188
+ checked={checked}
189
+ disabled={disabled}
190
+ frameWidth={frameWidth - leftBorderWidth * 2}
191
+ theme={checked ? 'active' : null}
192
+ themeShallow
193
+ role="switch"
194
+ aria-checked={checked}
195
+ aria-labelledby={labelledBy}
196
+ aria-required={required}
197
+ data-state={getState(checked)}
198
+ data-disabled={disabled ? '' : undefined}
199
+ // @ts-ignore
200
+ tabIndex={disabled ? undefined : 0}
201
+ // @ts-ignore
202
+ value={value}
203
+ {...switchProps}
204
+ ref={composedRefs}
205
+ onPress={composeEventHandlers(props.onPress, (event) => {
206
+ setChecked((prevChecked) => !prevChecked)
207
+ if (isWeb && isFormControl) {
208
+ hasConsumerStoppedPropagationRef.current = event.isPropagationStopped()
209
+ // if switch is in a form, stop propagation from the button so that we only propagate
210
+ // one click event (from the input). We propagate changes from an input so that native
211
+ // form validation works and form events reflect switch updates.
212
+ if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation()
213
+ }
214
+ })}
215
+ // @ts-ignore
216
+ onLayout={composeEventHandlers(props.onLayout, (e) =>
217
+ // @ts-ignore
218
+ setFrameWidth(e.nativeEvent.layout.width)
219
+ )}
220
+ />
221
+ {isWeb && isFormControl && (
222
+ <BubbleInput
223
+ control={button}
224
+ bubbles={!hasConsumerStoppedPropagationRef.current}
225
+ name={name}
226
+ value={value}
227
+ checked={checked}
228
+ required={required}
229
+ disabled={disabled}
230
+ // We transform because the input is absolutely positioned but we have
231
+ // rendered it **after** the button. This pulls it back to sit on top
232
+ // of the button.
233
+ style={{ transform: 'translateX(-100%)' }}
234
+ />
235
+ )}
236
+ </>
237
+ )
238
+ })
239
+ )
240
+
241
+ /* ---------------------------------------------------------------------------------------------- */
242
+
243
+ type InputProps = React.HTMLProps<'input'>
244
+
245
+ interface BubbleInputProps extends Omit<InputProps, 'checked'> {
246
+ checked: boolean
247
+ control: HTMLElement | null
248
+ bubbles: boolean
249
+ }
250
+
251
+ // TODO make this native friendly
252
+ const BubbleInput = (props: BubbleInputProps) => {
253
+ const { control, checked, bubbles = true, ...inputProps } = props
254
+ const ref = React.useRef<HTMLInputElement>(null)
255
+ const prevChecked = usePrevious(checked)
256
+ // const controlSize = useSize(control)
257
+
258
+ // Bubble checked change to parents (e.g form change event)
259
+ React.useEffect(() => {
260
+ const input = ref.current!
261
+ const inputProto = window.HTMLInputElement.prototype
262
+ const descriptor = Object.getOwnPropertyDescriptor(
263
+ inputProto,
264
+ 'checked'
265
+ ) as PropertyDescriptor
266
+ const setChecked = descriptor.set
267
+ if (prevChecked !== checked && setChecked) {
268
+ const event = new Event('click', { bubbles })
269
+ setChecked.call(input, checked)
270
+ input.dispatchEvent(event)
271
+ }
272
+ }, [prevChecked, checked, bubbles])
273
+
274
+ return (
275
+ // @ts-ignore
276
+ <input
277
+ type="checkbox"
278
+ aria-hidden
279
+ defaultChecked={checked}
280
+ {...inputProps}
281
+ tabIndex={-1}
282
+ ref={ref}
283
+ style={{
284
+ ...props.style,
285
+ // ...controlSize,
286
+ position: 'absolute',
287
+ pointerEvents: 'none',
288
+ opacity: 0,
289
+ margin: 0,
290
+ }}
291
+ />
292
+ )
293
+ }
294
+
295
+ function getState(checked: boolean) {
296
+ return checked ? 'checked' : 'unchecked'
297
+ }
298
+
299
+ const Switch = withStaticProperties(SwitchComponent, {
300
+ Thumb: SwitchThumb,
301
+ })
302
+
303
+ return Switch
304
+ }
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './Switch'
2
+ export * from './createSwitch'