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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.cjs +4323 -0
- package/dist/index.d.cts +861 -0
- package/dist/index.d.mts +861 -0
- package/dist/index.d.ts +861 -0
- package/dist/index.mjs +4285 -0
- package/package.json +51 -0
- package/src/components/actions/Button.tsx +337 -0
- package/src/components/actions/Checkbox.tsx +216 -0
- package/src/components/actions/CheckboxCards.tsx +257 -0
- package/src/components/actions/CheckboxGroup.tsx +249 -0
- package/src/components/actions/index.ts +4 -0
- package/src/components/layout/Box.tsx +108 -0
- package/src/components/layout/Flex.tsx +149 -0
- package/src/components/layout/Grid.tsx +224 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/playground/ThemeControls.tsx +456 -0
- package/src/components/playground/index.ts +1 -0
- package/src/components/typography/Blockquote.tsx +137 -0
- package/src/components/typography/Code.tsx +164 -0
- package/src/components/typography/Em.tsx +68 -0
- package/src/components/typography/Heading.tsx +136 -0
- package/src/components/typography/Kbd.tsx +162 -0
- package/src/components/typography/Link.tsx +173 -0
- package/src/components/typography/Quote.tsx +71 -0
- package/src/components/typography/Strong.tsx +70 -0
- package/src/components/typography/Text.tsx +140 -0
- package/src/components/typography/index.ts +9 -0
- package/src/hooks/useResolveColor.ts +24 -0
- package/src/hooks/useThemeContext.ts +11 -0
- package/src/index.ts +63 -0
- package/src/theme/Theme.tsx +12 -0
- package/src/theme/ThemeContext.ts +4 -0
- package/src/theme/ThemeImpl.tsx +54 -0
- package/src/theme/ThemeRoot.tsx +65 -0
- package/src/theme/createTheme.tsx +17 -0
- package/src/theme/resolveGrayColor.ts +38 -0
- package/src/theme/theme.types.ts +95 -0
- package/src/tokens/colors/amber.ts +28 -0
- package/src/tokens/colors/blue.ts +28 -0
- package/src/tokens/colors/bronze.ts +28 -0
- package/src/tokens/colors/brown.ts +28 -0
- package/src/tokens/colors/crimson.ts +28 -0
- package/src/tokens/colors/cyan.ts +28 -0
- package/src/tokens/colors/gold.ts +28 -0
- package/src/tokens/colors/grass.ts +28 -0
- package/src/tokens/colors/gray.ts +28 -0
- package/src/tokens/colors/green.ts +28 -0
- package/src/tokens/colors/index.ts +36 -0
- package/src/tokens/colors/indigo.ts +28 -0
- package/src/tokens/colors/iris.ts +28 -0
- package/src/tokens/colors/jade.ts +28 -0
- package/src/tokens/colors/lime.ts +28 -0
- package/src/tokens/colors/mauve.ts +28 -0
- package/src/tokens/colors/mint.ts +28 -0
- package/src/tokens/colors/olive.ts +28 -0
- package/src/tokens/colors/orange.ts +28 -0
- package/src/tokens/colors/pink.ts +28 -0
- package/src/tokens/colors/plum.ts +28 -0
- package/src/tokens/colors/purple.ts +28 -0
- package/src/tokens/colors/red.ts +28 -0
- package/src/tokens/colors/ruby.ts +28 -0
- package/src/tokens/colors/sage.ts +28 -0
- package/src/tokens/colors/sand.ts +28 -0
- package/src/tokens/colors/sky.ts +28 -0
- package/src/tokens/colors/slate.ts +28 -0
- package/src/tokens/colors/teal.ts +28 -0
- package/src/tokens/colors/tomato.ts +28 -0
- package/src/tokens/colors/types.ts +69 -0
- package/src/tokens/colors/violet.ts +28 -0
- package/src/tokens/colors/yellow.ts +28 -0
- package/src/tokens/index.ts +5 -0
- package/src/tokens/radius.ts +56 -0
- package/src/tokens/scaling.ts +10 -0
- package/src/tokens/spacing.ts +21 -0
- package/src/tokens/typography.ts +60 -0
- package/src/utils/applyScaling.ts +6 -0
- package/src/utils/classicEffect.ts +46 -0
- package/src/utils/resolveColor.ts +69 -0
- 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'
|