@butternutbox/pawprint-native 0.6.0 → 0.8.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.8.0",
4
4
  "type": "module",
5
5
  "description": "ButternutBox Pawprint Design System - React Native Components",
6
6
  "main": "./dist/index.cjs",
@@ -77,7 +77,9 @@ const StyledThumb = styled(Animated.View)<{
77
77
  const StyledContent = styled(View)<{
78
78
  contentGap: number
79
79
  }>(({ contentGap }) => ({
80
- gap: contentGap
80
+ gap: contentGap,
81
+ flex: 1,
82
+ minWidth: 0
81
83
  }))
82
84
 
83
85
  /**
@@ -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"
@@ -53,6 +53,7 @@ export const AllInlineVariants = {
53
53
  type="error"
54
54
  title="Optional headline here"
55
55
  onClose={() => {}}
56
+ link={{ label: "Optional link" }}
56
57
  >
57
58
  Try to keep the copy length here to a maximum of 2 lines
58
59
  </Notification>
@@ -60,6 +61,7 @@ export const AllInlineVariants = {
60
61
  type="success"
61
62
  title="Optional headline here"
62
63
  onClose={() => {}}
64
+ link={{ label: "Optional link" }}
63
65
  >
64
66
  Try to keep the copy length here to a maximum of 2 lines
65
67
  </Notification>
@@ -67,6 +69,7 @@ export const AllInlineVariants = {
67
69
  type="warning"
68
70
  title="Optional headline here"
69
71
  onClose={() => {}}
72
+ link={{ label: "Optional link" }}
70
73
  >
71
74
  Try to keep the copy length here to a maximum of 2 lines
72
75
  </Notification>
@@ -74,6 +77,7 @@ export const AllInlineVariants = {
74
77
  type="info"
75
78
  title="Optional headline here"
76
79
  onClose={() => {}}
80
+ link={{ label: "Optional link" }}
77
81
  >
78
82
  Try to keep the copy length here to a maximum of 2 lines
79
83
  </Notification>
@@ -278,30 +278,36 @@ export const Notification = React.forwardRef<View, NotificationProps>(
278
278
  rootAlignCenter={isLarge}
279
279
  {...(rest as ViewProps)}
280
280
  >
281
- {showIcon && (
282
- <Icon
283
- icon={IconComponent}
284
- size="md"
285
- colour={type}
286
- aria-label={type}
287
- />
288
- )}
289
- <View style={{ flexDirection: "column", gap: 8, flex: 1 }}>
290
- <Typography
291
- token={systemNotifications.notifications.typography.default}
292
- color={systemNotifications.notification.colour.text.default}
293
- >
294
- {children}
295
- </Typography>
296
- {link && (
297
- <Link
298
- href={link.href}
299
- onPress={link.onPress}
300
- weight="semiBold"
281
+ <View style={{ flexDirection: "column", flex: 1 }}>
282
+ {showIcon && (
283
+ <Icon
284
+ icon={IconComponent}
301
285
  size="md"
286
+ colour={type}
287
+ aria-label={type}
288
+ />
289
+ )}
290
+ <View style={{ flexDirection: "column", gap: 8, flex: 1 }}>
291
+ <Typography
292
+ token={systemNotifications.notifications.typography.default}
293
+ color={systemNotifications.notification.colour.text.default}
294
+ >
295
+ {children}
296
+ </Typography>
297
+ </View>
298
+ {link && (
299
+ <StyledLinkWrapper
300
+ linkPaddingLeft={parseTokenValue(sizeTokens.content.gap)}
302
301
  >
303
- {link.label}
304
- </Link>
302
+ <Link
303
+ href={link.href}
304
+ onPress={link.onPress}
305
+ weight="semiBold"
306
+ size="md"
307
+ >
308
+ {link.label}
309
+ </Link>
310
+ </StyledLinkWrapper>
305
311
  )}
306
312
  </View>
307
313
  </StyledSystemRoot>
@@ -418,22 +424,23 @@ export const Notification = React.forwardRef<View, NotificationProps>(
418
424
  >
419
425
  {children}
420
426
  </Typography>
427
+
428
+ {link && (
429
+ <StyledLinkWrapper
430
+ linkPaddingLeft={parseTokenValue(content.copy.spacing.gap)}
431
+ >
432
+ <Link
433
+ href={link.href}
434
+ onPress={link.onPress}
435
+ weight="semiBold"
436
+ size="md"
437
+ >
438
+ {link.label}
439
+ </Link>
440
+ </StyledLinkWrapper>
441
+ )}
421
442
  </StyledInlineCopy>
422
443
  </StyledCopyRow>
423
- {link && (
424
- <StyledLinkWrapper
425
- linkPaddingLeft={parseTokenValue(content.copy.spacing.gap)}
426
- >
427
- <Link
428
- href={link.href}
429
- onPress={link.onPress}
430
- weight="semiBold"
431
- size="md"
432
- >
433
- {link.label}
434
- </Link>
435
- </StyledLinkWrapper>
436
- )}
437
444
  </StyledContents>
438
445
  {onClose && (
439
446
  <StyledCloseButton
@@ -1,5 +1,6 @@
1
1
  import React from "react"
2
2
  import { View, StyleSheet } from "react-native"
3
+ import { Home } from "@butternutbox/pawprint-icons/core"
3
4
  import { TabNavigation } from "./TabNavigation"
4
5
  import type { TabNavigationProps } from "./TabNavigation"
5
6
  import { Typography } from "../../atoms/Typography"
@@ -149,6 +150,40 @@ export const States = {
149
150
  )
150
151
  }
151
152
 
153
+ const DOG_PHOTO_URL = "https://placedog.net/200/200"
154
+
155
+ export const WithIconsAndAvatars = {
156
+ name: "With Icons & Avatars",
157
+ render: () => (
158
+ <View style={styles.container}>
159
+ <TabNavigation defaultValue="home" layout="fixed">
160
+ <TabNavigation.List>
161
+ <TabNavigation.Tab value="home" icon={Home}>
162
+ Home
163
+ </TabNavigation.Tab>
164
+ <TabNavigation.Tab
165
+ value="dog"
166
+ avatar={{
167
+ src: DOG_PHOTO_URL,
168
+ alt: "Your dog",
169
+ size: "sm",
170
+ border: "sm"
171
+ }}
172
+ >
173
+ Your dog
174
+ </TabNavigation.Tab>
175
+ </TabNavigation.List>
176
+ <TabNavigation.Panel value="home">
177
+ <PanelBody>A leading icon sits before the label.</PanelBody>
178
+ </TabNavigation.Panel>
179
+ <TabNavigation.Panel value="dog">
180
+ <PanelBody>The Avatar atom can lead the label too.</PanelBody>
181
+ </TabNavigation.Panel>
182
+ </TabNavigation>
183
+ </View>
184
+ )
185
+ }
186
+
152
187
  export const AnimatedContent = {
153
188
  name: "Animated Content (fade)",
154
189
  render: () => (