radix-native 0.0.1

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/index.cjs +4323 -0
  4. package/dist/index.d.cts +861 -0
  5. package/dist/index.d.mts +861 -0
  6. package/dist/index.d.ts +861 -0
  7. package/dist/index.mjs +4285 -0
  8. package/package.json +51 -0
  9. package/src/components/actions/Button.tsx +337 -0
  10. package/src/components/actions/Checkbox.tsx +216 -0
  11. package/src/components/actions/CheckboxCards.tsx +257 -0
  12. package/src/components/actions/CheckboxGroup.tsx +249 -0
  13. package/src/components/actions/index.ts +4 -0
  14. package/src/components/layout/Box.tsx +108 -0
  15. package/src/components/layout/Flex.tsx +149 -0
  16. package/src/components/layout/Grid.tsx +224 -0
  17. package/src/components/layout/index.ts +9 -0
  18. package/src/components/playground/ThemeControls.tsx +456 -0
  19. package/src/components/playground/index.ts +1 -0
  20. package/src/components/typography/Blockquote.tsx +137 -0
  21. package/src/components/typography/Code.tsx +164 -0
  22. package/src/components/typography/Em.tsx +68 -0
  23. package/src/components/typography/Heading.tsx +136 -0
  24. package/src/components/typography/Kbd.tsx +162 -0
  25. package/src/components/typography/Link.tsx +173 -0
  26. package/src/components/typography/Quote.tsx +71 -0
  27. package/src/components/typography/Strong.tsx +70 -0
  28. package/src/components/typography/Text.tsx +140 -0
  29. package/src/components/typography/index.ts +9 -0
  30. package/src/hooks/useResolveColor.ts +24 -0
  31. package/src/hooks/useThemeContext.ts +11 -0
  32. package/src/index.ts +63 -0
  33. package/src/theme/Theme.tsx +12 -0
  34. package/src/theme/ThemeContext.ts +4 -0
  35. package/src/theme/ThemeImpl.tsx +54 -0
  36. package/src/theme/ThemeRoot.tsx +65 -0
  37. package/src/theme/createTheme.tsx +17 -0
  38. package/src/theme/resolveGrayColor.ts +38 -0
  39. package/src/theme/theme.types.ts +95 -0
  40. package/src/tokens/colors/amber.ts +28 -0
  41. package/src/tokens/colors/blue.ts +28 -0
  42. package/src/tokens/colors/bronze.ts +28 -0
  43. package/src/tokens/colors/brown.ts +28 -0
  44. package/src/tokens/colors/crimson.ts +28 -0
  45. package/src/tokens/colors/cyan.ts +28 -0
  46. package/src/tokens/colors/gold.ts +28 -0
  47. package/src/tokens/colors/grass.ts +28 -0
  48. package/src/tokens/colors/gray.ts +28 -0
  49. package/src/tokens/colors/green.ts +28 -0
  50. package/src/tokens/colors/index.ts +36 -0
  51. package/src/tokens/colors/indigo.ts +28 -0
  52. package/src/tokens/colors/iris.ts +28 -0
  53. package/src/tokens/colors/jade.ts +28 -0
  54. package/src/tokens/colors/lime.ts +28 -0
  55. package/src/tokens/colors/mauve.ts +28 -0
  56. package/src/tokens/colors/mint.ts +28 -0
  57. package/src/tokens/colors/olive.ts +28 -0
  58. package/src/tokens/colors/orange.ts +28 -0
  59. package/src/tokens/colors/pink.ts +28 -0
  60. package/src/tokens/colors/plum.ts +28 -0
  61. package/src/tokens/colors/purple.ts +28 -0
  62. package/src/tokens/colors/red.ts +28 -0
  63. package/src/tokens/colors/ruby.ts +28 -0
  64. package/src/tokens/colors/sage.ts +28 -0
  65. package/src/tokens/colors/sand.ts +28 -0
  66. package/src/tokens/colors/sky.ts +28 -0
  67. package/src/tokens/colors/slate.ts +28 -0
  68. package/src/tokens/colors/teal.ts +28 -0
  69. package/src/tokens/colors/tomato.ts +28 -0
  70. package/src/tokens/colors/types.ts +69 -0
  71. package/src/tokens/colors/violet.ts +28 -0
  72. package/src/tokens/colors/yellow.ts +28 -0
  73. package/src/tokens/index.ts +5 -0
  74. package/src/tokens/radius.ts +56 -0
  75. package/src/tokens/scaling.ts +10 -0
  76. package/src/tokens/spacing.ts +21 -0
  77. package/src/tokens/typography.ts +60 -0
  78. package/src/utils/applyScaling.ts +6 -0
  79. package/src/utils/classicEffect.ts +46 -0
  80. package/src/utils/resolveColor.ts +69 -0
  81. package/src/utils/resolveSpace.ts +13 -0
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "radix-native",
3
+ "version": "0.0.1",
4
+ "description": "Radix Themes API for React Native",
5
+ "author": "Atotaro98",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Atotaro98/radix-native"
10
+ },
11
+ "homepage": "https://github.com/Atotaro98/radix-native",
12
+ "keywords": ["react-native", "radix", "ui", "components", "expo", "themes"],
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "source": "src/index",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "react-native": "./src/index.ts",
20
+ "import": "./dist/index.mjs",
21
+ "default": "./dist/index.cjs"
22
+ }
23
+ },
24
+ "main": "dist/index.cjs",
25
+ "module": "dist/index.mjs",
26
+ "types": "dist/index.d.ts",
27
+ "files": [
28
+ "src",
29
+ "dist",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "build": "unbuild",
35
+ "dev": "unbuild --watch",
36
+ "generate:colors": "tsx scripts/generate-colors.ts",
37
+ "lint": "eslint src",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=18.0.0",
42
+ "react-native": ">=0.72.0"
43
+ },
44
+ "devDependencies": {
45
+ "@radix-ui/colors": "latest",
46
+ "@types/node": "latest",
47
+ "tsx": "latest",
48
+ "typescript": "latest",
49
+ "unbuild": "latest"
50
+ }
51
+ }
@@ -0,0 +1,337 @@
1
+ import React, { useCallback, useMemo } from 'react'
2
+ import { Pressable, ActivityIndicator, View } from 'react-native'
3
+ import { Text as RNText } from 'react-native'
4
+ import type {
5
+ PressableProps,
6
+ StyleProp,
7
+ ViewStyle,
8
+ TextStyle,
9
+ GestureResponderEvent,
10
+ } from 'react-native'
11
+ import { useThemeContext } from '../../hooks/useThemeContext'
12
+ import { useResolveColor } from '../../hooks/useResolveColor'
13
+ import { resolveSpace } from '../../utils/resolveSpace'
14
+ import { fontSize, lineHeight, letterSpacingEm } from '../../tokens/typography'
15
+ import { scalingMap } from '../../tokens/scaling'
16
+ import { getRadius, getFullRadius } from '../../tokens/radius'
17
+ import type { RadiusToken, RadiusLevel } from '../../tokens/radius'
18
+ import type { MarginToken } from '../../tokens/spacing'
19
+ import type { AccentColor } from '../../tokens/colors/types'
20
+ import { getClassicEffect } from '../../utils/classicEffect'
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export type ButtonSize = 1 | 2 | 3 | 4
25
+ export type ButtonVariant = 'classic' | 'solid' | 'soft' | 'surface' | 'outline' | 'ghost'
26
+
27
+ export interface ButtonProps extends Omit<PressableProps, 'style' | 'children'> {
28
+ /** Button size (1–4). Default: 2. */
29
+ size?: ButtonSize
30
+ /** Visual variant. Default: 'solid'. */
31
+ variant?: ButtonVariant
32
+ /** Accent color. Default: theme accent. */
33
+ color?: AccentColor
34
+ /** Increases color contrast with the background. */
35
+ highContrast?: boolean
36
+ /** Override the theme radius for this button. */
37
+ radius?: RadiusToken
38
+ /** Shows a loading spinner and disables the button. */
39
+ loading?: boolean
40
+ /** Disables the button. */
41
+ disabled?: boolean
42
+ /** Button content (usually a string). */
43
+ children?: React.ReactNode
44
+ // ─── Margin props ──────────────────────────────────────────────────────────
45
+ m?: MarginToken
46
+ mx?: MarginToken
47
+ my?: MarginToken
48
+ mt?: MarginToken
49
+ mr?: MarginToken
50
+ mb?: MarginToken
51
+ ml?: MarginToken
52
+ style?: StyleProp<ViewStyle>
53
+ }
54
+
55
+ // ─── Size mappings (Radix tokens) ─────────────────────────────────────────────
56
+
57
+ /** height per size: space-5, space-6, space-7, space-8 */
58
+ const SIZE_HEIGHT: Record<ButtonSize, number> = { 1: 24, 2: 32, 3: 40, 4: 48 }
59
+ /** horizontal padding per size (non-ghost) */
60
+ const SIZE_PADDING_X: Record<ButtonSize, number> = { 1: 8, 2: 12, 3: 16, 4: 24 }
61
+ /** horizontal padding per size (ghost) — smaller than non-ghost */
62
+ const GHOST_PADDING_X: Record<ButtonSize, number> = { 1: 8, 2: 8, 3: 12, 4: 16 }
63
+ /** vertical padding per size (ghost only) */
64
+ const GHOST_PADDING_Y: Record<ButtonSize, number> = { 1: 4, 2: 4, 3: 6, 4: 8 }
65
+ /** gap per size (non-ghost) */
66
+ const SIZE_GAP: Record<ButtonSize, number> = { 1: 4, 2: 8, 3: 12, 4: 12 }
67
+ /** gap per size (ghost) — smaller than non-ghost */
68
+ const GHOST_GAP: Record<ButtonSize, number> = { 1: 4, 2: 4, 3: 8, 4: 8 }
69
+ /** font-size index per button size */
70
+ const SIZE_FONT: Record<ButtonSize, 1 | 2 | 3 | 4> = { 1: 1, 2: 2, 3: 3, 4: 4 }
71
+ /** radius level per size */
72
+ const SIZE_RADIUS_LEVEL: Record<ButtonSize, RadiusLevel> = { 1: 1, 2: 2, 3: 3, 4: 4 }
73
+
74
+ // ─── Component ────────────────────────────────────────────────────────────────
75
+
76
+ export function Button({
77
+ size = 2,
78
+ variant = 'solid',
79
+ color,
80
+ highContrast,
81
+ radius: radiusProp,
82
+ loading = false,
83
+ disabled = false,
84
+ children,
85
+ m, mx, my, mt, mr, mb, ml,
86
+ style,
87
+ onPress,
88
+ ...rest
89
+ }: ButtonProps) {
90
+ const { appearance, scaling, fonts, radius: themeRadius } = useThemeContext()
91
+ const rc = useResolveColor()
92
+
93
+ const effectiveRadius = radiusProp ?? themeRadius
94
+ const isDisabled = disabled || loading
95
+ const prefix = color ?? 'accent'
96
+
97
+ // ─── Space helper ──────────────────────────────────────────────────────────
98
+ const sp = (token: MarginToken | undefined): number | undefined =>
99
+ token !== undefined ? resolveSpace(token, scaling) : undefined
100
+
101
+ // ─── Typography ────────────────────────────────────────────────────────────
102
+ const scalingFactor = scalingMap[scaling]
103
+ const fontIdx = SIZE_FONT[size]
104
+ const resolvedFontSize = Math.round(fontSize[fontIdx] * scalingFactor)
105
+ const resolvedLineHeight = Math.round(lineHeight[fontIdx] * scalingFactor)
106
+ const resolvedLetterSpacing = letterSpacingEm[fontIdx] * resolvedFontSize
107
+
108
+ // ─── Dimensions ────────────────────────────────────────────────────────────
109
+ const isGhost = variant === 'ghost'
110
+ const resolvedHeight = Math.round(SIZE_HEIGHT[size] * scalingFactor)
111
+ const resolvedPaddingX = Math.round(
112
+ (isGhost ? GHOST_PADDING_X[size] : SIZE_PADDING_X[size]) * scalingFactor,
113
+ )
114
+ const resolvedPaddingY = isGhost ? Math.round(GHOST_PADDING_Y[size] * scalingFactor) : 0
115
+ const resolvedGap = Math.round(
116
+ (isGhost ? GHOST_GAP[size] : SIZE_GAP[size]) * scalingFactor,
117
+ )
118
+
119
+ // ─── Radius ────────────────────────────────────────────────────────────────
120
+ const level = SIZE_RADIUS_LEVEL[size]
121
+ const borderRadius = Math.max(getRadius(effectiveRadius, level), getFullRadius(effectiveRadius))
122
+
123
+ // ─── Color helpers ─────────────────────────────────────────────────────────
124
+ type C = Parameters<typeof rc>[0]
125
+
126
+ const colors = useMemo(() => {
127
+ // Radix: loading sets [data-disabled], so both disabled AND loading use disabled styling
128
+ if (isDisabled) {
129
+ const disabledText = rc('gray-a8' as C)
130
+ switch (variant) {
131
+ case 'classic':
132
+ return {
133
+ bg: rc('gray-2' as C),
134
+ text: disabledText,
135
+ border: undefined,
136
+ pressedBg: rc('gray-2' as C),
137
+ }
138
+ case 'solid':
139
+ case 'soft':
140
+ return {
141
+ bg: rc('gray-a3' as C),
142
+ text: disabledText,
143
+ border: undefined,
144
+ pressedBg: rc('gray-a3' as C),
145
+ }
146
+ case 'surface':
147
+ return {
148
+ bg: rc('gray-a2' as C),
149
+ text: disabledText,
150
+ border: rc('gray-a6' as C),
151
+ pressedBg: rc('gray-a2' as C),
152
+ }
153
+ case 'outline':
154
+ return {
155
+ bg: 'transparent',
156
+ text: disabledText,
157
+ border: rc('gray-a7' as C),
158
+ pressedBg: 'transparent',
159
+ }
160
+ case 'ghost':
161
+ return {
162
+ bg: 'transparent',
163
+ text: disabledText,
164
+ border: undefined,
165
+ pressedBg: 'transparent',
166
+ }
167
+ }
168
+ }
169
+
170
+ // Active variant colors
171
+ const hc = highContrast
172
+
173
+ switch (variant) {
174
+ case 'classic':
175
+ case 'solid': {
176
+ const bg = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-9` as C)
177
+ const text = hc ? rc('gray-1' as C) : rc(`${prefix}-contrast` as C)
178
+ const pressedBg = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-10` as C)
179
+ // highContrast: accent-12 bg and pressedBg are identical, use opacity for press feedback
180
+ const pressedOpacity = hc ? 0.88 : undefined
181
+ return { bg, text, border: undefined, pressedBg, pressedOpacity }
182
+ }
183
+ case 'soft': {
184
+ const bg = rc(`${prefix}-a3` as C)
185
+ const text = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-a11` as C)
186
+ const pressedBg = rc(`${prefix}-a5` as C)
187
+ return { bg, text, border: undefined, pressedBg }
188
+ }
189
+ case 'surface': {
190
+ const bg = rc(`${prefix}-surface` as C)
191
+ const text = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-a11` as C)
192
+ const border = rc(`${prefix}-a7` as C)
193
+ const pressedBg = rc(`${prefix}-a3` as C)
194
+ return { bg, text, border, pressedBg }
195
+ }
196
+ case 'outline': {
197
+ const text = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-a11` as C)
198
+ // Radix highContrast: double inset shadow with accent-a7 + gray-a11
199
+ const border = hc ? rc('gray-a11' as C) : rc(`${prefix}-a8` as C)
200
+ const pressedBg = hc ? rc(`${prefix}-a3` as C) : rc(`${prefix}-a2` as C)
201
+ return { bg: 'transparent', text, border, pressedBg }
202
+ }
203
+ case 'ghost': {
204
+ const text = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-a11` as C)
205
+ const pressedBg = rc(`${prefix}-a4` as C)
206
+ return { bg: 'transparent', text, border: undefined, pressedBg }
207
+ }
208
+ }
209
+ }, [variant, prefix, highContrast, isDisabled, loading, rc])
210
+
211
+ // ─── Font family ───────────────────────────────────────────────────────────
212
+ // Radix: only non-ghost uses font-weight: medium; ghost uses regular
213
+ const fontFamily = isGhost ? (fonts.regular) : (fonts.medium ?? fonts.regular)
214
+
215
+ // ─── Press handler ─────────────────────────────────────────────────────────
216
+ const handlePress = useCallback(
217
+ (e: GestureResponderEvent) => {
218
+ if (!isDisabled && onPress) onPress(e)
219
+ },
220
+ [isDisabled, onPress],
221
+ )
222
+
223
+ // ─── Variant flags ──────────────────────────────────────────────────────────
224
+ const isClassic = variant === 'classic'
225
+
226
+ // ─── Render ────────────────────────────────────────────────────────────────
227
+ return (
228
+ <Pressable
229
+ onPress={handlePress}
230
+ disabled={isDisabled}
231
+ accessibilityRole="button"
232
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
233
+ style={({ pressed }) => {
234
+ const bg = pressed && !isDisabled ? colors.pressedBg : colors.bg
235
+ const opacity = (pressed && !isDisabled && colors.pressedOpacity != null)
236
+ ? colors.pressedOpacity
237
+ : (isDisabled && !loading ? 1 : undefined)
238
+
239
+ const containerStyle: ViewStyle = {
240
+ flexDirection: 'row',
241
+ alignItems: 'center',
242
+ justifyContent: 'center',
243
+ alignSelf: 'flex-start',
244
+ overflow: 'hidden',
245
+ height: isGhost ? undefined : resolvedHeight,
246
+ paddingHorizontal: resolvedPaddingX,
247
+ paddingVertical: isGhost ? resolvedPaddingY : undefined,
248
+ gap: resolvedGap,
249
+ backgroundColor: bg,
250
+ borderRadius,
251
+ borderWidth: colors.border ? 1 : undefined,
252
+ borderColor: colors.border,
253
+ opacity,
254
+ // Margins
255
+ marginTop: sp(mt ?? my ?? m),
256
+ marginBottom: sp(mb ?? my ?? m),
257
+ marginLeft: sp(ml ?? mx ?? m),
258
+ marginRight: sp(mr ?? mx ?? m),
259
+ }
260
+
261
+ // Classic 3D effect (shadow + bevel)
262
+ const classicStyle = isClassic
263
+ ? getClassicEffect(appearance, { pressed, disabled: isDisabled && !loading })
264
+ : undefined
265
+
266
+ return [containerStyle, classicStyle, style] as StyleProp<ViewStyle>
267
+ }}
268
+ {...rest}
269
+ >
270
+ {/* Classic gradient simulation: light overlay on top, dark on bottom */}
271
+ {isClassic && !isDisabled && (
272
+ <>
273
+ <View
274
+ pointerEvents="none"
275
+ style={{
276
+ position: 'absolute',
277
+ top: 0,
278
+ left: 0,
279
+ right: 0,
280
+ height: '50%',
281
+ backgroundColor: 'rgba(255,255,255,0.12)',
282
+ }}
283
+ />
284
+ <View
285
+ pointerEvents="none"
286
+ style={{
287
+ position: 'absolute',
288
+ bottom: 0,
289
+ left: 0,
290
+ right: 0,
291
+ height: '50%',
292
+ backgroundColor: 'rgba(0,0,0,0.08)',
293
+ }}
294
+ />
295
+ </>
296
+ )}
297
+ {loading ? (
298
+ <View style={{ position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
299
+ {/* Invisible children to maintain dimensions */}
300
+ <View style={{ opacity: 0, flexDirection: 'row', alignItems: 'center', gap: resolvedGap }}>
301
+ {renderContent(children, {
302
+ fontSize: resolvedFontSize,
303
+ lineHeight: resolvedLineHeight,
304
+ letterSpacing: resolvedLetterSpacing,
305
+ color: colors.text,
306
+ fontWeight: isGhost ? '400' : '500',
307
+ fontFamily,
308
+ })}
309
+ </View>
310
+ {/* Spinner overlay */}
311
+ <View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center' }}>
312
+ <ActivityIndicator size="small" color={colors.text} />
313
+ </View>
314
+ </View>
315
+ ) : (
316
+ renderContent(children, {
317
+ fontSize: resolvedFontSize,
318
+ lineHeight: resolvedLineHeight,
319
+ letterSpacing: resolvedLetterSpacing,
320
+ color: colors.text,
321
+ fontWeight: isGhost ? '400' : '500',
322
+ fontFamily,
323
+ })
324
+ )}
325
+ </Pressable>
326
+ )
327
+ }
328
+ Button.displayName = 'Button'
329
+
330
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
331
+
332
+ function renderContent(children: React.ReactNode, textStyle: TextStyle): React.ReactNode {
333
+ if (typeof children === 'string' || typeof children === 'number') {
334
+ return <RNText style={textStyle}>{children}</RNText>
335
+ }
336
+ return children
337
+ }
@@ -0,0 +1,216 @@
1
+ import React, { useCallback, useMemo } from 'react'
2
+ import { Pressable, View } from 'react-native'
3
+ import { Text as RNText } from 'react-native'
4
+ import type {
5
+ PressableProps,
6
+ StyleProp,
7
+ ViewStyle,
8
+ TextStyle,
9
+ } from 'react-native'
10
+ import { useThemeContext } from '../../hooks/useThemeContext'
11
+ import { useResolveColor } from '../../hooks/useResolveColor'
12
+ import { resolveSpace } from '../../utils/resolveSpace'
13
+ import { scalingMap } from '../../tokens/scaling'
14
+ import { getRadius } from '../../tokens/radius'
15
+ import type { MarginToken } from '../../tokens/spacing'
16
+ import type { AccentColor } from '../../tokens/colors/types'
17
+ import { getClassicEffect } from '../../utils/classicEffect'
18
+
19
+ // ─── Types ────────────────────────────────────────────────────────────────────
20
+
21
+ export type CheckboxSize = 1 | 2 | 3
22
+ export type CheckboxVariant = 'classic' | 'surface' | 'soft'
23
+ export type CheckedState = boolean | 'indeterminate'
24
+
25
+ export interface CheckboxProps extends Omit<PressableProps, 'style' | 'children'> {
26
+ /** Checkbox size (1–3). Default: 2. */
27
+ size?: CheckboxSize
28
+ /** Visual variant. Default: 'surface'. */
29
+ variant?: CheckboxVariant
30
+ /** Accent color. Default: theme accent. */
31
+ color?: AccentColor
32
+ /** Increases color contrast with the background. */
33
+ highContrast?: boolean
34
+ /** Controlled checked state. */
35
+ checked?: CheckedState
36
+ /** Uncontrolled default checked state. */
37
+ defaultChecked?: CheckedState
38
+ /** Called when the checked state changes. */
39
+ onCheckedChange?: (checked: CheckedState) => void
40
+ /** Disables the checkbox. */
41
+ disabled?: boolean
42
+ // ─── Margin props ──────────────────────────────────────────────────────────
43
+ m?: MarginToken
44
+ mx?: MarginToken
45
+ my?: MarginToken
46
+ mt?: MarginToken
47
+ mr?: MarginToken
48
+ mb?: MarginToken
49
+ ml?: MarginToken
50
+ style?: StyleProp<ViewStyle>
51
+ }
52
+
53
+ // ─── Size mappings ────────────────────────────────────────────────────────────
54
+
55
+ /** Checkbox size in px (space-4 = 16, scaled by 0.875 / 1 / 1.25) */
56
+ const SIZE_PX: Record<CheckboxSize, number> = { 1: 14, 2: 16, 3: 20 }
57
+ /** Indicator (check/dash) size in px */
58
+ const INDICATOR_SIZE: Record<CheckboxSize, number> = { 1: 9, 2: 10, 3: 12 }
59
+ /** Radius multiplier per size (relative to radius-level-1) */
60
+ const RADIUS_MULT: Record<CheckboxSize, number> = { 1: 0.875, 2: 1, 3: 1.25 }
61
+
62
+ // ─── Component ────────────────────────────────────────────────────────────────
63
+
64
+ export function Checkbox({
65
+ size = 2,
66
+ variant = 'surface',
67
+ color,
68
+ highContrast,
69
+ checked: checkedProp,
70
+ defaultChecked = false,
71
+ onCheckedChange,
72
+ disabled = false,
73
+ m, mx, my, mt, mr, mb, ml,
74
+ style,
75
+ ...rest
76
+ }: CheckboxProps) {
77
+ const { appearance, scaling, radius } = useThemeContext()
78
+ const rc = useResolveColor()
79
+
80
+ // ─── Controlled / uncontrolled ─────────────────────────────────────────────
81
+ const [internal, setInternal] = React.useState<CheckedState>(defaultChecked)
82
+ const isControlled = checkedProp !== undefined
83
+ const checkedState = isControlled ? checkedProp : internal
84
+ const isChecked = checkedState === true
85
+ const isIndeterminate = checkedState === 'indeterminate'
86
+ const isActive = isChecked || isIndeterminate
87
+
88
+ // ─── Space helper ──────────────────────────────────────────────────────────
89
+ const sp = (token: MarginToken | undefined): number | undefined =>
90
+ token !== undefined ? resolveSpace(token, scaling) : undefined
91
+
92
+ // ─── Dimensions ────────────────────────────────────────────────────────────
93
+ const scalingFactor = scalingMap[scaling]
94
+ const boxSize = Math.round(SIZE_PX[size] * scalingFactor)
95
+ const indicatorSize = Math.round(INDICATOR_SIZE[size] * scalingFactor)
96
+ const borderRadius = Math.round(getRadius(radius, 1) * RADIUS_MULT[size])
97
+
98
+ const prefix = color ?? 'accent'
99
+ type C = Parameters<typeof rc>[0]
100
+
101
+ // ─── Colors ────────────────────────────────────────────────────────────────
102
+ const colors = useMemo(() => {
103
+ if (disabled) {
104
+ const indicator = isActive ? rc('gray-a8' as C) : undefined
105
+ switch (variant) {
106
+ case 'classic':
107
+ case 'surface':
108
+ return {
109
+ bg: rc('gray-a3' as C),
110
+ border: rc('gray-a6' as C),
111
+ indicator,
112
+ showBorder: true,
113
+ }
114
+ case 'soft':
115
+ return {
116
+ bg: rc('gray-a3' as C),
117
+ border: undefined,
118
+ indicator,
119
+ showBorder: false,
120
+ }
121
+ }
122
+ }
123
+
124
+ const hc = highContrast
125
+
126
+ switch (variant) {
127
+ case 'classic':
128
+ case 'surface': {
129
+ if (isActive) {
130
+ const bg = hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-9` as C)
131
+ const indicator = hc ? rc(`${prefix}-1` as C) : rc(`${prefix}-contrast` as C)
132
+ return { bg, border: undefined, indicator, showBorder: false }
133
+ }
134
+ return {
135
+ bg: rc('gray-surface' as C),
136
+ border: rc('gray-a7' as C),
137
+ indicator: undefined,
138
+ showBorder: true,
139
+ }
140
+ }
141
+ case 'soft': {
142
+ const bg = rc(`${prefix}-a5` as C)
143
+ const indicator = isActive
144
+ ? (hc ? rc(`${prefix}-12` as C) : rc(`${prefix}-a11` as C))
145
+ : undefined
146
+ return { bg, border: undefined, indicator, showBorder: false }
147
+ }
148
+ }
149
+ }, [variant, prefix, highContrast, isActive, disabled, rc])
150
+
151
+ // ─── Toggle ────────────────────────────────────────────────────────────────
152
+ const handlePress = useCallback(() => {
153
+ if (disabled) return
154
+ const next: CheckedState = isChecked ? false : true
155
+ if (!isControlled) setInternal(next)
156
+ onCheckedChange?.(next)
157
+ }, [disabled, isChecked, isControlled, onCheckedChange])
158
+
159
+ // ─── Classic effect ────────────────────────────────────────────────────────
160
+ const isClassic = variant === 'classic'
161
+ const classicStyle = isClassic
162
+ ? getClassicEffect(appearance, { disabled })
163
+ : undefined
164
+
165
+ // ─── Styles ────────────────────────────────────────────────────────────────
166
+ const boxStyle: ViewStyle = {
167
+ width: boxSize,
168
+ height: boxSize,
169
+ borderRadius,
170
+ backgroundColor: colors.bg,
171
+ borderWidth: colors.showBorder ? 1 : undefined,
172
+ borderColor: colors.border,
173
+ alignItems: 'center',
174
+ justifyContent: 'center',
175
+ // Margins
176
+ marginTop: sp(mt ?? my ?? m),
177
+ marginBottom: sp(mb ?? my ?? m),
178
+ marginLeft: sp(ml ?? mx ?? m),
179
+ marginRight: sp(mr ?? mx ?? m),
180
+ }
181
+
182
+ const indicatorStyle: TextStyle = {
183
+ fontSize: indicatorSize,
184
+ lineHeight: indicatorSize + 2,
185
+ color: colors.indicator,
186
+ fontWeight: '700',
187
+ }
188
+
189
+ return (
190
+ <Pressable
191
+ onPress={handlePress}
192
+ disabled={disabled}
193
+ accessibilityRole="checkbox"
194
+ accessibilityState={{
195
+ checked: isIndeterminate ? 'mixed' : isChecked,
196
+ disabled,
197
+ }}
198
+ style={[boxStyle, classicStyle, style]}
199
+ {...rest}
200
+ >
201
+ {/* Classic gradient simulation when checked — wrapped with overflow:hidden for borderRadius clipping */}
202
+ {isClassic && isActive && !disabled && (
203
+ <View pointerEvents="none" style={{ position: 'absolute', inset: 0, overflow: 'hidden', borderRadius }}>
204
+ <View style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', backgroundColor: 'rgba(255,255,255,0.12)' }} />
205
+ <View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%', backgroundColor: 'rgba(0,0,0,0.08)' }} />
206
+ </View>
207
+ )}
208
+ {isActive && (
209
+ <RNText style={indicatorStyle}>
210
+ {isIndeterminate ? '\u2013' : '\u2713'}
211
+ </RNText>
212
+ )}
213
+ </Pressable>
214
+ )
215
+ }
216
+ Checkbox.displayName = 'Checkbox'