@butternutbox/pawprint-native 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butternutbox/pawprint-native",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,98 @@
1
+ import React, { useState } from "react"
2
+ import { View } from "react-native"
3
+ import { NativeSelectPicker } from "./NativeSelectPicker"
4
+
5
+ export default {
6
+ title: "Molecules/NativeSelectPicker",
7
+ component: NativeSelectPicker,
8
+ argTypes: {
9
+ disabled: {
10
+ control: "boolean"
11
+ },
12
+ hideIcon: {
13
+ control: "boolean"
14
+ }
15
+ },
16
+ decorators: [
17
+ (Story: React.ComponentType) => (
18
+ <View style={{ padding: 20, flex: 1, justifyContent: "center" }}>
19
+ <Story />
20
+ </View>
21
+ )
22
+ ]
23
+ }
24
+
25
+ const BANK_OPTIONS = [
26
+ { label: "ABN Amro", value: "abn_amro" },
27
+ { label: "ASN Bank", value: "asn_bank" },
28
+ { label: "bunq B.V.", value: "bunq" },
29
+ { label: "ING Bank", value: "ing" },
30
+ { label: "Knab", value: "knab" },
31
+ { label: "Rabobank", value: "rabobank" }
32
+ ]
33
+
34
+ export const Basic = () => {
35
+ const [value, setValue] = useState<string | number | null>(null)
36
+ return (
37
+ <NativeSelectPicker
38
+ items={BANK_OPTIONS}
39
+ placeholder={{ label: "Select a bank" }}
40
+ value={value}
41
+ onValueChange={setValue}
42
+ />
43
+ )
44
+ }
45
+
46
+ export const WithSelectedValue = () => {
47
+ const [value, setValue] = useState<string | number | null>("ing")
48
+ return (
49
+ <NativeSelectPicker
50
+ items={BANK_OPTIONS}
51
+ placeholder={{ label: "Select a bank" }}
52
+ value={value}
53
+ onValueChange={setValue}
54
+ />
55
+ )
56
+ }
57
+
58
+ export const Disabled = () => {
59
+ const [value, setValue] = useState<string | number | null>(null)
60
+ return (
61
+ <NativeSelectPicker
62
+ items={BANK_OPTIONS}
63
+ placeholder={{ label: "Select a bank" }}
64
+ value={value}
65
+ onValueChange={setValue}
66
+ disabled
67
+ />
68
+ )
69
+ }
70
+
71
+ export const HideIcon = () => {
72
+ const [value, setValue] = useState<string | number | null>(null)
73
+ return (
74
+ <NativeSelectPicker
75
+ items={BANK_OPTIONS}
76
+ placeholder={{ label: "Select a bank" }}
77
+ value={value}
78
+ onValueChange={setValue}
79
+ hideIcon
80
+ />
81
+ )
82
+ }
83
+
84
+ export const NumericValues = () => {
85
+ const [value, setValue] = useState<string | number | null>(null)
86
+ return (
87
+ <NativeSelectPicker
88
+ items={[
89
+ { label: "Option 1", value: 1 },
90
+ { label: "Option 2", value: 2 },
91
+ { label: "Option 3", value: 3 }
92
+ ]}
93
+ placeholder={{ label: "Select an option" }}
94
+ value={value}
95
+ onValueChange={setValue}
96
+ />
97
+ )
98
+ }
@@ -0,0 +1,127 @@
1
+ import styled from "@emotion/native"
2
+ import { View, Text } from "react-native"
3
+ import Animated from "react-native-reanimated"
4
+ import { DEFAULT_THEME_OPTIONS } from "@butternutbox/pawprint-tokens"
5
+
6
+ type AndroidOptionTextProps = {
7
+ isSelected: boolean
8
+ }
9
+
10
+ const themeTokens = DEFAULT_THEME_OPTIONS.tokens
11
+ const { components, semantics } = themeTokens
12
+ const inputTokens = components.inputs
13
+ const { dimensions } = semantics
14
+
15
+ const parseSize = (value: string): number => {
16
+ return parseInt(value.replace("px", ""), 10)
17
+ }
18
+
19
+ const Wrapper = styled(View)({
20
+ borderWidth: parseSize(inputTokens.borderWidth.field.selected),
21
+ borderColor: inputTokens.colour.field.border.selected,
22
+ borderRadius: parseSize(inputTokens.borderRadius.field.default),
23
+ backgroundColor: inputTokens.colour.field.background.default,
24
+ paddingHorizontal: parseSize(inputTokens.spacing.field.horizontalPadding),
25
+ paddingVertical: parseSize(inputTokens.spacing.field.verticalPadding),
26
+ height: parseSize(inputTokens.size.field.height),
27
+ flexDirection: "row",
28
+ alignItems: "center",
29
+ justifyContent: "space-between"
30
+ })
31
+
32
+ const SelectIcon = styled(Animated.View)({
33
+ position: "absolute",
34
+ right: 0,
35
+ marginRight: 0
36
+ })
37
+
38
+ const InputText = styled(Text)({
39
+ color: inputTokens.colour.field.text.default,
40
+ fontSize: parseSize(inputTokens.field.placeholder.default.fontSize),
41
+ fontWeight: inputTokens.field.placeholder.default.fontWeight,
42
+ flex: 1
43
+ })
44
+
45
+ const PlaceholderText = styled(Text)({
46
+ color: inputTokens.colour.field.text.placeholder,
47
+ fontSize: parseSize(inputTokens.field.placeholder.default.fontSize),
48
+ fontWeight: inputTokens.field.placeholder.default.fontWeight,
49
+ flex: 1
50
+ })
51
+
52
+ const ModalOverlay = styled(View)({
53
+ flex: 1,
54
+ backgroundColor: "rgba(0, 0, 0, 0.32)",
55
+ justifyContent: "flex-end"
56
+ })
57
+
58
+ const AndroidDialog = styled(View)({
59
+ backgroundColor: inputTokens.colour.field.background.default,
60
+ borderTopLeftRadius: parseSize(dimensions.borderRadius.lg),
61
+ borderTopRightRadius: parseSize(dimensions.borderRadius.lg),
62
+ maxHeight: "80%",
63
+ minHeight: "40%",
64
+ paddingTop: parseSize(dimensions.spacing.md),
65
+ paddingBottom: parseSize(dimensions.spacing.xl),
66
+ paddingHorizontal: parseSize(dimensions.spacing.md)
67
+ })
68
+
69
+ const DialogTitle = styled(Text)({
70
+ fontSize: parseSize(inputTokens.field.placeholder.default.fontSize),
71
+ fontWeight: inputTokens.text.label.fontWeight,
72
+ color: inputTokens.colour.field.text.default,
73
+ marginBottom: parseSize(dimensions.spacing.md),
74
+ marginLeft: parseSize(dimensions.spacing.xs)
75
+ })
76
+
77
+ const AndroidOptionRow = styled(View)({
78
+ flexDirection: "row",
79
+ alignItems: "center",
80
+ paddingVertical: parseSize(dimensions.spacing.md),
81
+ paddingHorizontal: parseSize(dimensions.spacing.sm),
82
+ borderBottomWidth: parseSize(dimensions.borderWidth.sm),
83
+ borderBottomColor: themeTokens.primitives.colour.brand.brown[2]
84
+ })
85
+
86
+ const RadioOuter = styled(View)({
87
+ width: parseSize(dimensions.spacing.lg),
88
+ height: parseSize(dimensions.spacing.lg),
89
+ borderRadius: parseSize(dimensions.spacing.lg) / 2,
90
+ borderWidth: parseSize(dimensions.borderWidth.md),
91
+ borderColor: inputTokens.colour.field.border.selected,
92
+ alignItems: "center",
93
+ justifyContent: "center",
94
+ marginRight: parseSize(dimensions.spacing.sm)
95
+ })
96
+
97
+ const RadioInner = styled(View)({
98
+ width: parseSize(dimensions.spacing.md) / 2,
99
+ height: parseSize(dimensions.spacing.md) / 2,
100
+ borderRadius: parseSize(dimensions.spacing.md) / 4,
101
+ backgroundColor: inputTokens.colour.field.border.selected
102
+ })
103
+
104
+ const AndroidOptionText = styled(Text)<AndroidOptionTextProps>(
105
+ ({ isSelected }) => ({
106
+ fontSize: parseSize(inputTokens.field.placeholder.default.fontSize),
107
+ fontWeight: isSelected
108
+ ? inputTokens.text.label.fontWeight
109
+ : inputTokens.field.placeholder.default.fontWeight,
110
+ color: inputTokens.colour.field.text.default,
111
+ flex: 1
112
+ })
113
+ )
114
+
115
+ export {
116
+ Wrapper,
117
+ SelectIcon,
118
+ InputText,
119
+ PlaceholderText,
120
+ ModalOverlay,
121
+ AndroidDialog,
122
+ DialogTitle,
123
+ AndroidOptionRow,
124
+ RadioOuter,
125
+ RadioInner,
126
+ AndroidOptionText
127
+ }
@@ -0,0 +1,250 @@
1
+ import React, { useCallback, useMemo, useState } from "react"
2
+ import {
3
+ Platform,
4
+ ActionSheetIOS,
5
+ Modal,
6
+ TouchableOpacity,
7
+ FlatList,
8
+ TouchableWithoutFeedback,
9
+ View,
10
+ ViewProps
11
+ } from "react-native"
12
+ import { useSharedValue, useAnimatedStyle } from "react-native-reanimated"
13
+ import { KeyboardArrowDown } from "@butternutbox/pawprint-icons/core"
14
+ import { DEFAULT_THEME_OPTIONS } from "@butternutbox/pawprint-tokens"
15
+
16
+ import * as S from "./NativeSelectPicker.styled"
17
+
18
+ const themeTokens = DEFAULT_THEME_OPTIONS.tokens
19
+ const { components, semantics } = themeTokens
20
+ const inputTokens = components.inputs
21
+ const { dimensions } = semantics
22
+
23
+ const parseSize = (value: string): number => {
24
+ return parseInt(value.replace("px", ""), 10)
25
+ }
26
+
27
+ export type NativeSelectPickerItem = {
28
+ label: string
29
+ value: string | number
30
+ key?: string | number
31
+ }
32
+
33
+ export type NativeSelectPickerProps = ViewProps & {
34
+ items: NativeSelectPickerItem[]
35
+ value?: string | number | null
36
+ placeholder?: {
37
+ label?: string
38
+ value?: string | number
39
+ }
40
+ onValueChange: (value: string | number) => void
41
+ disabled?: boolean
42
+ hideIcon?: boolean
43
+ }
44
+
45
+ type IconAnimation = {
46
+ iconAnimationStyles: {
47
+ transform: Array<{ rotate: string }>
48
+ }
49
+ }
50
+
51
+ const useIconAnimation = (): IconAnimation => {
52
+ const iconRotation = useSharedValue(0)
53
+
54
+ const iconAnimationStyles = useAnimatedStyle(() => {
55
+ "worklet"
56
+ return {
57
+ transform: [{ rotate: `${iconRotation.value}deg` }]
58
+ }
59
+ })
60
+
61
+ return {
62
+ iconAnimationStyles
63
+ }
64
+ }
65
+
66
+ /**
67
+ * NativeSelectPicker is a native UI dropdown component that uses:
68
+ * - ActionSheetIOS on iOS (native system picker)
69
+ * - Modal with FlatList on Android
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * <NativeSelectPicker
74
+ * items={[
75
+ * { label: "Option 1", value: "opt1" },
76
+ * { label: "Option 2", value: "opt2" }
77
+ * ]}
78
+ * placeholder={{ label: "Select an option" }}
79
+ * value={selectedValue}
80
+ * onValueChange={setSelectedValue}
81
+ * />
82
+ * ```
83
+ */
84
+ const NativeSelectPicker = React.forwardRef<View, NativeSelectPickerProps>(
85
+ (
86
+ {
87
+ items,
88
+ value,
89
+ placeholder,
90
+ onValueChange,
91
+ disabled = false,
92
+ hideIcon = false,
93
+ ...rest
94
+ },
95
+ ref
96
+ ) => {
97
+ const [isOpen, setIsOpen] = useState(false)
98
+ const { iconAnimationStyles } = useIconAnimation()
99
+
100
+ const hasPlaceholder =
101
+ placeholder &&
102
+ Object.keys(placeholder).length > 0 &&
103
+ placeholder.label !== undefined
104
+
105
+ const allItems = useMemo(() => {
106
+ if (hasPlaceholder) {
107
+ return [
108
+ {
109
+ label: placeholder.label!,
110
+ value: placeholder.value ?? "",
111
+ key: "__placeholder__"
112
+ },
113
+ ...items
114
+ ]
115
+ }
116
+ return items
117
+ }, [items, hasPlaceholder, placeholder])
118
+
119
+ const selectedItem = allItems.find((item) => item.value === value)
120
+ const displayLabel =
121
+ selectedItem?.label ?? (hasPlaceholder ? placeholder.label! : "")
122
+
123
+ const handleOpenIOS = useCallback(() => {
124
+ if (disabled) return
125
+
126
+ const options = [...allItems.map((item) => item.label), "Cancel"]
127
+ const cancelButtonIndex = options.length - 1
128
+
129
+ ActionSheetIOS.showActionSheetWithOptions(
130
+ {
131
+ options,
132
+ cancelButtonIndex
133
+ },
134
+ (buttonIndex) => {
135
+ if (buttonIndex !== cancelButtonIndex) {
136
+ onValueChange(allItems[buttonIndex].value)
137
+ }
138
+ }
139
+ )
140
+ }, [disabled, allItems, onValueChange])
141
+
142
+ const handleOpenAndroid = useCallback(() => {
143
+ if (!disabled) {
144
+ setIsOpen(true)
145
+ }
146
+ }, [disabled])
147
+
148
+ const handleClose = useCallback(() => {
149
+ setIsOpen(false)
150
+ }, [])
151
+
152
+ const handleSelect = useCallback(
153
+ (itemValue: string | number) => {
154
+ onValueChange(itemValue)
155
+ setIsOpen(false)
156
+ },
157
+ [onValueChange]
158
+ )
159
+
160
+ return (
161
+ <S.Wrapper ref={ref} {...rest}>
162
+ <TouchableOpacity
163
+ activeOpacity={0.8}
164
+ onPress={Platform.OS === "ios" ? handleOpenIOS : handleOpenAndroid}
165
+ disabled={disabled}
166
+ hitSlop={{
167
+ top: parseSize(dimensions.spacing.md),
168
+ right: parseSize(dimensions.spacing.md),
169
+ bottom: parseSize(dimensions.spacing.md),
170
+ left: parseSize(dimensions.spacing.md)
171
+ }}
172
+ accessibilityRole="button"
173
+ style={{ flexDirection: "row", alignItems: "center" }}
174
+ >
175
+ <S.InputText numberOfLines={1}>
176
+ {displayLabel || (
177
+ <S.PlaceholderText>
178
+ {placeholder?.label || "Select"}
179
+ </S.PlaceholderText>
180
+ )}
181
+ </S.InputText>
182
+ {!hideIcon && (
183
+ <S.SelectIcon style={[iconAnimationStyles]}>
184
+ <KeyboardArrowDown
185
+ width={parseSize(dimensions.spacing.xl)}
186
+ height={parseSize(dimensions.spacing.xl)}
187
+ color={inputTokens.colour.field.text.default}
188
+ />
189
+ </S.SelectIcon>
190
+ )}
191
+ </TouchableOpacity>
192
+
193
+ {Platform.OS === "android" && (
194
+ <Modal
195
+ visible={isOpen}
196
+ transparent
197
+ animationType="fade"
198
+ onRequestClose={handleClose}
199
+ >
200
+ <TouchableWithoutFeedback
201
+ accessibilityRole="button"
202
+ onPress={handleClose}
203
+ >
204
+ <S.ModalOverlay>
205
+ <TouchableWithoutFeedback>
206
+ <S.AndroidDialog>
207
+ <S.DialogTitle>{"Select an option"}</S.DialogTitle>
208
+ <FlatList
209
+ data={allItems}
210
+ keyExtractor={(item, index) =>
211
+ item.key?.toString() ?? `${item.value}-${index}`
212
+ }
213
+ renderItem={({ item }) => {
214
+ const isSelected = item.value === value
215
+
216
+ return (
217
+ <TouchableOpacity
218
+ onPress={() => handleSelect(item.value)}
219
+ activeOpacity={0.7}
220
+ accessibilityRole="button"
221
+ >
222
+ <S.AndroidOptionRow>
223
+ <S.RadioOuter>
224
+ {isSelected && <S.RadioInner />}
225
+ </S.RadioOuter>
226
+ <S.AndroidOptionText isSelected={isSelected}>
227
+ {item.label}
228
+ </S.AndroidOptionText>
229
+ </S.AndroidOptionRow>
230
+ </TouchableOpacity>
231
+ )
232
+ }}
233
+ style={{
234
+ maxHeight: parseSize(dimensions.spacing["8xl"])
235
+ }}
236
+ />
237
+ </S.AndroidDialog>
238
+ </TouchableWithoutFeedback>
239
+ </S.ModalOverlay>
240
+ </TouchableWithoutFeedback>
241
+ </Modal>
242
+ )}
243
+ </S.Wrapper>
244
+ )
245
+ }
246
+ )
247
+
248
+ NativeSelectPicker.displayName = "NativeSelectPicker"
249
+
250
+ export { NativeSelectPicker }
@@ -0,0 +1,5 @@
1
+ export { NativeSelectPicker } from "./NativeSelectPicker"
2
+ export type {
3
+ NativeSelectPickerProps,
4
+ NativeSelectPickerItem
5
+ } from "./NativeSelectPicker"
@@ -13,6 +13,7 @@ export * from "./Radio"
13
13
  export * from "./SearchField"
14
14
  export * from "./SegmentedControl"
15
15
  export * from "./SelectField"
16
+ export * from "./NativeSelectPicker"
16
17
  export * from "./Slider"
17
18
  export * from "./Notification"
18
19
  export * from "./Tooltip"