@onlynative/components 0.1.0-alpha.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.
- package/README.md +99 -0
- package/dist/appbar/index.d.ts +71 -0
- package/dist/appbar/index.js +952 -0
- package/dist/button/index.d.ts +41 -0
- package/dist/button/index.js +454 -0
- package/dist/card/index.d.ts +31 -0
- package/dist/card/index.js +264 -0
- package/dist/checkbox/index.d.ts +25 -0
- package/dist/checkbox/index.js +291 -0
- package/dist/chip/index.d.ts +62 -0
- package/dist/chip/index.js +452 -0
- package/dist/icon-button/index.d.ts +10 -0
- package/dist/icon-button/index.js +575 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +3374 -0
- package/dist/layout/index.d.ts +98 -0
- package/dist/layout/index.js +282 -0
- package/dist/list/index.d.ts +60 -0
- package/dist/list/index.js +300 -0
- package/dist/radio/index.d.ts +25 -0
- package/dist/radio/index.js +250 -0
- package/dist/switch/index.d.ts +37 -0
- package/dist/switch/index.js +315 -0
- package/dist/text-field/index.d.ts +52 -0
- package/dist/text-field/index.js +496 -0
- package/dist/types-D3hlyvz-.d.ts +51 -0
- package/dist/typography/index.d.ts +28 -0
- package/dist/typography/index.js +69 -0
- package/package.json +166 -0
- package/src/appbar/AppBar.tsx +302 -0
- package/src/appbar/index.ts +2 -0
- package/src/appbar/styles.ts +92 -0
- package/src/appbar/types.ts +67 -0
- package/src/button/Button.tsx +130 -0
- package/src/button/index.ts +2 -0
- package/src/button/styles.ts +288 -0
- package/src/button/types.ts +42 -0
- package/src/card/Card.tsx +69 -0
- package/src/card/index.ts +2 -0
- package/src/card/styles.ts +151 -0
- package/src/card/types.ts +27 -0
- package/src/checkbox/Checkbox.tsx +109 -0
- package/src/checkbox/index.ts +2 -0
- package/src/checkbox/styles.ts +155 -0
- package/src/checkbox/types.ts +20 -0
- package/src/chip/Chip.tsx +182 -0
- package/src/chip/index.ts +2 -0
- package/src/chip/styles.ts +240 -0
- package/src/chip/types.ts +58 -0
- package/src/icon-button/IconButton.tsx +358 -0
- package/src/icon-button/index.ts +6 -0
- package/src/icon-button/styles.ts +259 -0
- package/src/icon-button/types.ts +55 -0
- package/src/index.ts +51 -0
- package/src/layout/Box.tsx +99 -0
- package/src/layout/Column.tsx +16 -0
- package/src/layout/Grid.tsx +49 -0
- package/src/layout/Layout.tsx +81 -0
- package/src/layout/Row.tsx +22 -0
- package/src/layout/index.ts +13 -0
- package/src/layout/resolveSpacing.ts +11 -0
- package/src/layout/types.ts +82 -0
- package/src/list/List.tsx +17 -0
- package/src/list/ListDivider.tsx +20 -0
- package/src/list/ListItem.tsx +128 -0
- package/src/list/index.ts +9 -0
- package/src/list/styles.ts +132 -0
- package/src/list/types.ts +54 -0
- package/src/radio/Radio.tsx +103 -0
- package/src/radio/index.ts +2 -0
- package/src/radio/styles.ts +139 -0
- package/src/radio/types.ts +20 -0
- package/src/switch/Switch.tsx +118 -0
- package/src/switch/index.ts +2 -0
- package/src/switch/styles.ts +172 -0
- package/src/switch/types.ts +32 -0
- package/src/test-utils/render-with-theme.tsx +13 -0
- package/src/text-field/TextField.tsx +298 -0
- package/src/text-field/index.ts +2 -0
- package/src/text-field/styles.ts +240 -0
- package/src/text-field/types.ts +49 -0
- package/src/typography/Typography.tsx +65 -0
- package/src/typography/index.ts +3 -0
- package/src/typography/types.ts +17 -0
- package/src/utils/color.ts +64 -0
- package/src/utils/elevation.ts +33 -0
- package/src/utils/rtl.ts +19 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PressableProps } from 'react-native'
|
|
2
|
+
|
|
3
|
+
export interface RadioProps extends Omit<PressableProps, 'children'> {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the radio button is selected.
|
|
6
|
+
* @default false
|
|
7
|
+
*/
|
|
8
|
+
value?: boolean
|
|
9
|
+
/** Callback fired when the radio is pressed. Receives the new value. */
|
|
10
|
+
onValueChange?: (value: boolean) => void
|
|
11
|
+
/**
|
|
12
|
+
* Override the outer ring and inner dot color when selected.
|
|
13
|
+
* State-layer colors (hover, press) are derived automatically.
|
|
14
|
+
*/
|
|
15
|
+
containerColor?: string
|
|
16
|
+
/**
|
|
17
|
+
* Override the outer ring color when unselected.
|
|
18
|
+
*/
|
|
19
|
+
contentColor?: string
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { Platform, Pressable, StyleSheet, View } from 'react-native'
|
|
4
|
+
import type { StyleProp, ViewStyle } from 'react-native'
|
|
5
|
+
import { useTheme } from '@onlynative/core'
|
|
6
|
+
|
|
7
|
+
import { createStyles } from './styles'
|
|
8
|
+
import type { SwitchProps } from './types'
|
|
9
|
+
|
|
10
|
+
interface PressableState {
|
|
11
|
+
pressed: boolean
|
|
12
|
+
hovered?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveStyle(
|
|
16
|
+
trackStyle: StyleProp<ViewStyle>,
|
|
17
|
+
hoveredTrackStyle: StyleProp<ViewStyle>,
|
|
18
|
+
pressedTrackStyle: StyleProp<ViewStyle>,
|
|
19
|
+
disabledTrackStyle: StyleProp<ViewStyle>,
|
|
20
|
+
disabled: boolean,
|
|
21
|
+
style: SwitchProps['style'],
|
|
22
|
+
): (state: PressableState) => StyleProp<ViewStyle> {
|
|
23
|
+
if (typeof style === 'function') {
|
|
24
|
+
return (state) => [
|
|
25
|
+
trackStyle,
|
|
26
|
+
state.hovered && !state.pressed && !disabled
|
|
27
|
+
? hoveredTrackStyle
|
|
28
|
+
: undefined,
|
|
29
|
+
state.pressed && !disabled ? pressedTrackStyle : undefined,
|
|
30
|
+
disabled ? disabledTrackStyle : undefined,
|
|
31
|
+
style(state),
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (state) => [
|
|
36
|
+
trackStyle,
|
|
37
|
+
state.hovered && !state.pressed && !disabled
|
|
38
|
+
? hoveredTrackStyle
|
|
39
|
+
: undefined,
|
|
40
|
+
state.pressed && !disabled ? pressedTrackStyle : undefined,
|
|
41
|
+
disabled ? disabledTrackStyle : undefined,
|
|
42
|
+
style,
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Switch({
|
|
47
|
+
style,
|
|
48
|
+
value = false,
|
|
49
|
+
onValueChange,
|
|
50
|
+
selectedIcon = 'check',
|
|
51
|
+
unselectedIcon,
|
|
52
|
+
containerColor,
|
|
53
|
+
contentColor,
|
|
54
|
+
disabled = false,
|
|
55
|
+
...props
|
|
56
|
+
}: SwitchProps) {
|
|
57
|
+
const isDisabled = Boolean(disabled)
|
|
58
|
+
const isSelected = Boolean(value)
|
|
59
|
+
const hasIcon = isSelected || Boolean(unselectedIcon)
|
|
60
|
+
|
|
61
|
+
const theme = useTheme()
|
|
62
|
+
const styles = useMemo(
|
|
63
|
+
() =>
|
|
64
|
+
createStyles(theme, isSelected, hasIcon, containerColor, contentColor),
|
|
65
|
+
[theme, isSelected, hasIcon, containerColor, contentColor],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const resolvedIconColor = useMemo(() => {
|
|
69
|
+
const base = StyleSheet.flatten([
|
|
70
|
+
styles.iconColor,
|
|
71
|
+
isDisabled ? styles.disabledIconColor : undefined,
|
|
72
|
+
])
|
|
73
|
+
return typeof base?.color === 'string' ? base.color : undefined
|
|
74
|
+
}, [styles.iconColor, styles.disabledIconColor, isDisabled])
|
|
75
|
+
|
|
76
|
+
const handlePress = () => {
|
|
77
|
+
if (!isDisabled) {
|
|
78
|
+
onValueChange?.(!isSelected)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const iconName = isSelected ? selectedIcon : unselectedIcon
|
|
83
|
+
const iconSize = 16
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Pressable
|
|
87
|
+
{...props}
|
|
88
|
+
accessibilityRole="switch"
|
|
89
|
+
accessibilityState={{
|
|
90
|
+
disabled: isDisabled,
|
|
91
|
+
checked: isSelected,
|
|
92
|
+
}}
|
|
93
|
+
hitSlop={Platform.OS === 'web' ? undefined : 4}
|
|
94
|
+
disabled={isDisabled}
|
|
95
|
+
onPress={handlePress}
|
|
96
|
+
style={resolveStyle(
|
|
97
|
+
styles.track,
|
|
98
|
+
styles.hoveredTrack,
|
|
99
|
+
styles.pressedTrack,
|
|
100
|
+
styles.disabledTrack,
|
|
101
|
+
isDisabled,
|
|
102
|
+
style,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<View
|
|
106
|
+
style={[styles.thumb, isDisabled ? styles.disabledThumb : undefined]}
|
|
107
|
+
>
|
|
108
|
+
{iconName ? (
|
|
109
|
+
<MaterialCommunityIcons
|
|
110
|
+
name={iconName}
|
|
111
|
+
size={iconSize}
|
|
112
|
+
color={resolvedIconColor}
|
|
113
|
+
/>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
</Pressable>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native'
|
|
2
|
+
import type { Theme } from '@onlynative/core'
|
|
3
|
+
|
|
4
|
+
import { alphaColor, blendColor } from '../utils/color'
|
|
5
|
+
|
|
6
|
+
interface TrackColors {
|
|
7
|
+
trackColor: string
|
|
8
|
+
thumbColor: string
|
|
9
|
+
iconColor: string
|
|
10
|
+
hoveredTrackColor: string
|
|
11
|
+
pressedTrackColor: string
|
|
12
|
+
borderColor: string
|
|
13
|
+
borderWidth: number
|
|
14
|
+
disabledTrackColor: string
|
|
15
|
+
disabledThumbColor: string
|
|
16
|
+
disabledBorderColor: string
|
|
17
|
+
disabledBorderWidth: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getColors(theme: Theme, selected: boolean): TrackColors {
|
|
21
|
+
const disabledOnSurface12 = alphaColor(theme.colors.onSurface, 0.12)
|
|
22
|
+
const disabledOnSurface38 = alphaColor(theme.colors.onSurface, 0.38)
|
|
23
|
+
|
|
24
|
+
if (selected) {
|
|
25
|
+
return {
|
|
26
|
+
trackColor: theme.colors.primary,
|
|
27
|
+
thumbColor: theme.colors.onPrimary,
|
|
28
|
+
iconColor: theme.colors.onPrimaryContainer,
|
|
29
|
+
hoveredTrackColor: blendColor(
|
|
30
|
+
theme.colors.primary,
|
|
31
|
+
theme.colors.onPrimary,
|
|
32
|
+
theme.stateLayer.hoveredOpacity,
|
|
33
|
+
),
|
|
34
|
+
pressedTrackColor: blendColor(
|
|
35
|
+
theme.colors.primary,
|
|
36
|
+
theme.colors.onPrimary,
|
|
37
|
+
theme.stateLayer.pressedOpacity,
|
|
38
|
+
),
|
|
39
|
+
borderColor: 'transparent',
|
|
40
|
+
borderWidth: 0,
|
|
41
|
+
disabledTrackColor: disabledOnSurface12,
|
|
42
|
+
disabledThumbColor: theme.colors.surface,
|
|
43
|
+
disabledBorderColor: 'transparent',
|
|
44
|
+
disabledBorderWidth: 0,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
trackColor: theme.colors.surfaceContainerHighest,
|
|
50
|
+
thumbColor: theme.colors.outline,
|
|
51
|
+
iconColor: theme.colors.surfaceContainerHighest,
|
|
52
|
+
hoveredTrackColor: blendColor(
|
|
53
|
+
theme.colors.surfaceContainerHighest,
|
|
54
|
+
theme.colors.onSurface,
|
|
55
|
+
theme.stateLayer.hoveredOpacity,
|
|
56
|
+
),
|
|
57
|
+
pressedTrackColor: blendColor(
|
|
58
|
+
theme.colors.surfaceContainerHighest,
|
|
59
|
+
theme.colors.onSurface,
|
|
60
|
+
theme.stateLayer.pressedOpacity,
|
|
61
|
+
),
|
|
62
|
+
borderColor: theme.colors.outline,
|
|
63
|
+
borderWidth: 2,
|
|
64
|
+
disabledTrackColor: disabledOnSurface12,
|
|
65
|
+
disabledThumbColor: disabledOnSurface38,
|
|
66
|
+
disabledBorderColor: disabledOnSurface12,
|
|
67
|
+
disabledBorderWidth: 2,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function applyColorOverrides(
|
|
72
|
+
theme: Theme,
|
|
73
|
+
colors: TrackColors,
|
|
74
|
+
containerColor?: string,
|
|
75
|
+
contentColor?: string,
|
|
76
|
+
): TrackColors {
|
|
77
|
+
if (!containerColor && !contentColor) return colors
|
|
78
|
+
|
|
79
|
+
const result = { ...colors }
|
|
80
|
+
|
|
81
|
+
if (contentColor) {
|
|
82
|
+
result.thumbColor = contentColor
|
|
83
|
+
result.iconColor = contentColor
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (containerColor) {
|
|
87
|
+
const overlay = contentColor ?? colors.thumbColor
|
|
88
|
+
result.trackColor = containerColor
|
|
89
|
+
result.borderColor = containerColor
|
|
90
|
+
result.hoveredTrackColor = blendColor(
|
|
91
|
+
containerColor,
|
|
92
|
+
overlay,
|
|
93
|
+
theme.stateLayer.hoveredOpacity,
|
|
94
|
+
)
|
|
95
|
+
result.pressedTrackColor = blendColor(
|
|
96
|
+
containerColor,
|
|
97
|
+
overlay,
|
|
98
|
+
theme.stateLayer.pressedOpacity,
|
|
99
|
+
)
|
|
100
|
+
if (contentColor) {
|
|
101
|
+
result.iconColor = containerColor
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createStyles(
|
|
109
|
+
theme: Theme,
|
|
110
|
+
selected: boolean,
|
|
111
|
+
hasIcon: boolean,
|
|
112
|
+
containerColor?: string,
|
|
113
|
+
contentColor?: string,
|
|
114
|
+
) {
|
|
115
|
+
const colors = applyColorOverrides(
|
|
116
|
+
theme,
|
|
117
|
+
getColors(theme, selected),
|
|
118
|
+
containerColor,
|
|
119
|
+
contentColor,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const thumbSize = selected || hasIcon ? 24 : 16
|
|
123
|
+
const trackWidth = 52
|
|
124
|
+
const trackHeight = 32
|
|
125
|
+
const trackPadding = 4
|
|
126
|
+
const thumbOffset = selected
|
|
127
|
+
? trackWidth - trackPadding - thumbSize
|
|
128
|
+
: trackPadding
|
|
129
|
+
|
|
130
|
+
return StyleSheet.create({
|
|
131
|
+
track: {
|
|
132
|
+
width: trackWidth,
|
|
133
|
+
height: trackHeight,
|
|
134
|
+
borderRadius: trackHeight / 2,
|
|
135
|
+
backgroundColor: colors.trackColor,
|
|
136
|
+
borderColor: colors.borderColor,
|
|
137
|
+
borderWidth: colors.borderWidth,
|
|
138
|
+
justifyContent: 'center',
|
|
139
|
+
cursor: 'pointer',
|
|
140
|
+
},
|
|
141
|
+
hoveredTrack: {
|
|
142
|
+
backgroundColor: colors.hoveredTrackColor,
|
|
143
|
+
},
|
|
144
|
+
pressedTrack: {
|
|
145
|
+
backgroundColor: colors.pressedTrackColor,
|
|
146
|
+
},
|
|
147
|
+
disabledTrack: {
|
|
148
|
+
backgroundColor: colors.disabledTrackColor,
|
|
149
|
+
borderColor: colors.disabledBorderColor,
|
|
150
|
+
borderWidth: colors.disabledBorderWidth,
|
|
151
|
+
cursor: 'auto',
|
|
152
|
+
},
|
|
153
|
+
thumb: {
|
|
154
|
+
width: thumbSize,
|
|
155
|
+
height: thumbSize,
|
|
156
|
+
borderRadius: thumbSize / 2,
|
|
157
|
+
backgroundColor: colors.thumbColor,
|
|
158
|
+
marginStart: thumbOffset,
|
|
159
|
+
alignItems: 'center' as const,
|
|
160
|
+
justifyContent: 'center' as const,
|
|
161
|
+
},
|
|
162
|
+
disabledThumb: {
|
|
163
|
+
backgroundColor: colors.disabledThumbColor,
|
|
164
|
+
},
|
|
165
|
+
iconColor: {
|
|
166
|
+
color: colors.iconColor,
|
|
167
|
+
},
|
|
168
|
+
disabledIconColor: {
|
|
169
|
+
color: alphaColor(theme.colors.onSurface, 0.38),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import type { PressableProps } from 'react-native'
|
|
4
|
+
|
|
5
|
+
export interface SwitchProps extends Omit<PressableProps, 'children'> {
|
|
6
|
+
/**
|
|
7
|
+
* Whether the switch is toggled on.
|
|
8
|
+
* @default false
|
|
9
|
+
*/
|
|
10
|
+
value?: boolean
|
|
11
|
+
/** Callback fired when the switch is toggled. Receives the new value. */
|
|
12
|
+
onValueChange?: (value: boolean) => void
|
|
13
|
+
/**
|
|
14
|
+
* Name of a MaterialCommunityIcons icon to show on the thumb when selected.
|
|
15
|
+
* @default 'check'
|
|
16
|
+
*/
|
|
17
|
+
selectedIcon?: ComponentProps<typeof MaterialCommunityIcons>['name']
|
|
18
|
+
/**
|
|
19
|
+
* Name of a MaterialCommunityIcons icon to show on the thumb when unselected.
|
|
20
|
+
* When provided, the thumb renders at the larger selected size.
|
|
21
|
+
*/
|
|
22
|
+
unselectedIcon?: ComponentProps<typeof MaterialCommunityIcons>['name']
|
|
23
|
+
/**
|
|
24
|
+
* Override the track color.
|
|
25
|
+
* State-layer colors (hover, press) are derived automatically.
|
|
26
|
+
*/
|
|
27
|
+
containerColor?: string
|
|
28
|
+
/**
|
|
29
|
+
* Override the thumb and icon color.
|
|
30
|
+
*/
|
|
31
|
+
contentColor?: string
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { render, type RenderOptions } from '@testing-library/react-native'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import { MaterialProvider } from '@onlynative/core'
|
|
4
|
+
|
|
5
|
+
export function renderWithTheme(
|
|
6
|
+
ui: ReactElement,
|
|
7
|
+
options?: Omit<RenderOptions, 'wrapper'>,
|
|
8
|
+
) {
|
|
9
|
+
return render(ui, {
|
|
10
|
+
wrapper: ({ children }) => <MaterialProvider>{children}</MaterialProvider>,
|
|
11
|
+
...options,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Animated,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
Text,
|
|
8
|
+
TextInput,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native'
|
|
11
|
+
import type { NativeSyntheticEvent, TargetedEvent } from 'react-native'
|
|
12
|
+
import { useTheme } from '@onlynative/core'
|
|
13
|
+
|
|
14
|
+
import { createStyles, labelPositions } from './styles'
|
|
15
|
+
import type { TextFieldProps } from './types'
|
|
16
|
+
|
|
17
|
+
const ICON_SIZE = 24
|
|
18
|
+
// 12dp icon inset + 24dp icon + 16dp gap
|
|
19
|
+
const ICON_WITH_GAP = 12 + 24 + 16
|
|
20
|
+
|
|
21
|
+
export function TextField({
|
|
22
|
+
value,
|
|
23
|
+
onChangeText,
|
|
24
|
+
label,
|
|
25
|
+
placeholder,
|
|
26
|
+
variant = 'filled',
|
|
27
|
+
supportingText,
|
|
28
|
+
errorText,
|
|
29
|
+
error = false,
|
|
30
|
+
disabled = false,
|
|
31
|
+
leadingIcon,
|
|
32
|
+
trailingIcon,
|
|
33
|
+
onTrailingIconPress,
|
|
34
|
+
multiline = false,
|
|
35
|
+
onFocus,
|
|
36
|
+
onBlur,
|
|
37
|
+
style,
|
|
38
|
+
containerColor,
|
|
39
|
+
contentColor,
|
|
40
|
+
inputStyle,
|
|
41
|
+
...textInputProps
|
|
42
|
+
}: TextFieldProps) {
|
|
43
|
+
const theme = useTheme()
|
|
44
|
+
const isDisabled = Boolean(disabled)
|
|
45
|
+
const isError = Boolean(error) || Boolean(errorText)
|
|
46
|
+
const isFilled = variant === 'filled'
|
|
47
|
+
const hasLeadingIcon = Boolean(leadingIcon)
|
|
48
|
+
|
|
49
|
+
const { colors, styles } = useMemo(
|
|
50
|
+
() => createStyles(theme, variant),
|
|
51
|
+
[theme, variant],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
55
|
+
const [internalHasText, setInternalHasText] = useState(
|
|
56
|
+
() => value !== undefined && value !== '',
|
|
57
|
+
)
|
|
58
|
+
const inputRef = useRef<TextInput>(null)
|
|
59
|
+
|
|
60
|
+
const isControlled = value !== undefined
|
|
61
|
+
const hasValue = isControlled ? value !== '' : internalHasText
|
|
62
|
+
const isLabelFloated = isFocused || hasValue
|
|
63
|
+
|
|
64
|
+
// Animation: 0 = resting (label large, centered), 1 = floated (label small, top)
|
|
65
|
+
const labelAnimRef = useRef(new Animated.Value(isLabelFloated ? 1 : 0))
|
|
66
|
+
const labelAnim = labelAnimRef.current
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
Animated.timing(labelAnim, {
|
|
70
|
+
toValue: isLabelFloated ? 1 : 0,
|
|
71
|
+
duration: 150,
|
|
72
|
+
useNativeDriver: Platform.OS !== 'web',
|
|
73
|
+
}).start()
|
|
74
|
+
}, [isLabelFloated, labelAnim])
|
|
75
|
+
|
|
76
|
+
// Scale: bodyLarge/bodySmall when resting → 1.0 when floated.
|
|
77
|
+
// Label is always rendered at bodySmall size; scale makes it appear as bodyLarge.
|
|
78
|
+
const labelScale = useMemo(() => {
|
|
79
|
+
const restingScale =
|
|
80
|
+
theme.typography.bodyLarge.fontSize / theme.typography.bodySmall.fontSize
|
|
81
|
+
return labelAnim.interpolate({
|
|
82
|
+
inputRange: [0, 1],
|
|
83
|
+
outputRange: [restingScale, 1],
|
|
84
|
+
})
|
|
85
|
+
}, [
|
|
86
|
+
labelAnim,
|
|
87
|
+
theme.typography.bodyLarge.fontSize,
|
|
88
|
+
theme.typography.bodySmall.fontSize,
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
// TranslateY: moves label from floated position down to resting position.
|
|
92
|
+
// Static top = floatedTop; translateY shifts it to restingTop when at rest.
|
|
93
|
+
const labelTranslateY = useMemo(() => {
|
|
94
|
+
const restingTop = isFilled
|
|
95
|
+
? labelPositions.filledRestingTop
|
|
96
|
+
: labelPositions.outlinedRestingTop
|
|
97
|
+
const floatedTop = isFilled
|
|
98
|
+
? labelPositions.filledFloatedTop
|
|
99
|
+
: labelPositions.outlinedFloatedTop
|
|
100
|
+
const restingOffset = restingTop - floatedTop
|
|
101
|
+
return labelAnim.interpolate({
|
|
102
|
+
inputRange: [0, 1],
|
|
103
|
+
outputRange: [restingOffset, 0],
|
|
104
|
+
})
|
|
105
|
+
}, [isFilled, labelAnim])
|
|
106
|
+
|
|
107
|
+
// Label start: 16dp container padding + leading icon space (12dp inset + 24dp + 16dp gap)
|
|
108
|
+
const labelStart =
|
|
109
|
+
theme.spacing.md + (hasLeadingIcon ? ICON_WITH_GAP - theme.spacing.md : 0)
|
|
110
|
+
// Static top = floated position; translateY handles resting offset
|
|
111
|
+
const labelStaticTop = isFilled
|
|
112
|
+
? labelPositions.filledFloatedTop
|
|
113
|
+
: labelPositions.outlinedFloatedTop
|
|
114
|
+
|
|
115
|
+
const handleChangeText = useCallback(
|
|
116
|
+
(text: string) => {
|
|
117
|
+
if (!isControlled) {
|
|
118
|
+
setInternalHasText(text !== '')
|
|
119
|
+
}
|
|
120
|
+
onChangeText?.(text)
|
|
121
|
+
},
|
|
122
|
+
[isControlled, onChangeText],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const handleFocus = useCallback(
|
|
126
|
+
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
127
|
+
if (isDisabled) return
|
|
128
|
+
setIsFocused(true)
|
|
129
|
+
onFocus?.(event)
|
|
130
|
+
},
|
|
131
|
+
[isDisabled, onFocus],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const handleBlur = useCallback(
|
|
135
|
+
(event: NativeSyntheticEvent<TargetedEvent>) => {
|
|
136
|
+
setIsFocused(false)
|
|
137
|
+
onBlur?.(event)
|
|
138
|
+
},
|
|
139
|
+
[onBlur],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const handleContainerPress = useCallback(() => {
|
|
143
|
+
if (!isDisabled) {
|
|
144
|
+
inputRef.current?.focus()
|
|
145
|
+
}
|
|
146
|
+
}, [isDisabled])
|
|
147
|
+
|
|
148
|
+
const labelColor = isDisabled
|
|
149
|
+
? colors.disabledLabelColor
|
|
150
|
+
: isError
|
|
151
|
+
? colors.errorLabelColor
|
|
152
|
+
: isFocused
|
|
153
|
+
? colors.focusedLabelColor
|
|
154
|
+
: colors.labelColor
|
|
155
|
+
|
|
156
|
+
const labelBackgroundColor =
|
|
157
|
+
variant === 'outlined' && isLabelFloated
|
|
158
|
+
? theme.colors.surface
|
|
159
|
+
: 'transparent'
|
|
160
|
+
|
|
161
|
+
const iconColor = isDisabled
|
|
162
|
+
? colors.disabledIconColor
|
|
163
|
+
: isError
|
|
164
|
+
? colors.errorIconColor
|
|
165
|
+
: contentColor ?? colors.iconColor
|
|
166
|
+
|
|
167
|
+
const containerStyle = useMemo(
|
|
168
|
+
() => [
|
|
169
|
+
styles.container,
|
|
170
|
+
containerColor && !isDisabled
|
|
171
|
+
? { backgroundColor: containerColor }
|
|
172
|
+
: undefined,
|
|
173
|
+
isFocused && styles.containerFocused,
|
|
174
|
+
isError && !isFocused && styles.containerError,
|
|
175
|
+
isDisabled && styles.containerDisabled,
|
|
176
|
+
],
|
|
177
|
+
[styles, isFocused, isError, isDisabled, containerColor],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const indicatorStyle = useMemo(
|
|
181
|
+
() => [
|
|
182
|
+
styles.indicator,
|
|
183
|
+
isFocused && styles.indicatorFocused,
|
|
184
|
+
isError && !isFocused && styles.indicatorError,
|
|
185
|
+
isDisabled && styles.indicatorDisabled,
|
|
186
|
+
],
|
|
187
|
+
[styles, isFocused, isError, isDisabled],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const displaySupportingText = isError ? errorText : supportingText
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<View style={[styles.root, style]}>
|
|
194
|
+
<Pressable onPress={handleContainerPress} disabled={isDisabled}>
|
|
195
|
+
<View style={containerStyle}>
|
|
196
|
+
{leadingIcon ? (
|
|
197
|
+
<View style={styles.leadingIcon}>
|
|
198
|
+
<MaterialCommunityIcons
|
|
199
|
+
name={leadingIcon}
|
|
200
|
+
size={ICON_SIZE}
|
|
201
|
+
color={iconColor}
|
|
202
|
+
/>
|
|
203
|
+
</View>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
<View
|
|
207
|
+
style={[
|
|
208
|
+
styles.inputWrapper,
|
|
209
|
+
label ? styles.inputWrapperWithLabel : undefined,
|
|
210
|
+
]}
|
|
211
|
+
>
|
|
212
|
+
<TextInput
|
|
213
|
+
ref={inputRef}
|
|
214
|
+
{...textInputProps}
|
|
215
|
+
value={value}
|
|
216
|
+
onChangeText={handleChangeText}
|
|
217
|
+
editable={!isDisabled}
|
|
218
|
+
onFocus={handleFocus}
|
|
219
|
+
onBlur={handleBlur}
|
|
220
|
+
placeholder={isLabelFloated || !label ? placeholder : undefined}
|
|
221
|
+
placeholderTextColor={colors.placeholderColor}
|
|
222
|
+
multiline={multiline}
|
|
223
|
+
style={[
|
|
224
|
+
styles.input,
|
|
225
|
+
isDisabled ? styles.inputDisabled : undefined,
|
|
226
|
+
contentColor && !isDisabled
|
|
227
|
+
? { color: contentColor }
|
|
228
|
+
: undefined,
|
|
229
|
+
inputStyle,
|
|
230
|
+
]}
|
|
231
|
+
accessibilityLabel={label || undefined}
|
|
232
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
233
|
+
accessibilityHint={isError && errorText ? errorText : undefined}
|
|
234
|
+
/>
|
|
235
|
+
</View>
|
|
236
|
+
|
|
237
|
+
{trailingIcon ? (
|
|
238
|
+
<Pressable
|
|
239
|
+
onPress={onTrailingIconPress}
|
|
240
|
+
disabled={isDisabled || !onTrailingIconPress}
|
|
241
|
+
accessibilityRole="button"
|
|
242
|
+
hitSlop={12}
|
|
243
|
+
style={styles.trailingIconPressable}
|
|
244
|
+
>
|
|
245
|
+
<View style={styles.trailingIcon}>
|
|
246
|
+
<MaterialCommunityIcons
|
|
247
|
+
name={trailingIcon}
|
|
248
|
+
size={ICON_SIZE}
|
|
249
|
+
color={iconColor}
|
|
250
|
+
/>
|
|
251
|
+
</View>
|
|
252
|
+
</Pressable>
|
|
253
|
+
) : null}
|
|
254
|
+
|
|
255
|
+
{/* Label: rendered at bodySmall, scaled up via transform when resting */}
|
|
256
|
+
{label ? (
|
|
257
|
+
<Animated.Text
|
|
258
|
+
numberOfLines={1}
|
|
259
|
+
style={[
|
|
260
|
+
styles.label,
|
|
261
|
+
{
|
|
262
|
+
top: labelStaticTop,
|
|
263
|
+
start: labelStart,
|
|
264
|
+
color: labelColor,
|
|
265
|
+
backgroundColor: labelBackgroundColor,
|
|
266
|
+
transform: [
|
|
267
|
+
{ translateY: labelTranslateY },
|
|
268
|
+
{ scale: labelScale },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
variant === 'outlined' && isLabelFloated
|
|
272
|
+
? styles.labelNotch
|
|
273
|
+
: undefined,
|
|
274
|
+
]}
|
|
275
|
+
>
|
|
276
|
+
{label}
|
|
277
|
+
</Animated.Text>
|
|
278
|
+
) : null}
|
|
279
|
+
|
|
280
|
+
{isFilled ? <View style={indicatorStyle} /> : null}
|
|
281
|
+
</View>
|
|
282
|
+
</Pressable>
|
|
283
|
+
|
|
284
|
+
{displaySupportingText ? (
|
|
285
|
+
<View style={styles.supportingTextRow}>
|
|
286
|
+
<Text
|
|
287
|
+
style={[
|
|
288
|
+
styles.supportingText,
|
|
289
|
+
isError ? styles.errorSupportingText : undefined,
|
|
290
|
+
]}
|
|
291
|
+
>
|
|
292
|
+
{displaySupportingText}
|
|
293
|
+
</Text>
|
|
294
|
+
</View>
|
|
295
|
+
) : null}
|
|
296
|
+
</View>
|
|
297
|
+
)
|
|
298
|
+
}
|