@butternutbox/pawprint-native 0.0.1 → 0.2.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.
Files changed (187) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +30 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12413 -1459
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1111 -13
  7. package/dist/index.d.ts +1111 -13
  8. package/dist/index.js +12365 -1457
  9. package/dist/index.js.map +1 -1
  10. package/package.json +29 -11
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.stories.tsx +2 -2
  42. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  43. package/src/components/atoms/Illustration/Illustration.tsx +3 -3
  44. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  45. package/src/components/atoms/Input/Input.test.tsx +306 -0
  46. package/src/components/atoms/Input/Input.tsx +9 -1
  47. package/src/components/atoms/Input/InputField.tsx +226 -74
  48. package/src/components/atoms/Link/Link.test.tsx +89 -0
  49. package/src/components/atoms/Link/Link.tsx +7 -6
  50. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  51. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  52. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  53. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  54. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  55. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  56. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  57. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  58. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  61. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  62. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  63. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  64. package/src/components/atoms/Logo/assets/index.ts +11 -0
  65. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  66. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  67. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  68. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  69. package/src/components/atoms/NumberInput/index.ts +4 -0
  70. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  71. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  72. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  73. package/src/components/atoms/Switch/Switch.tsx +28 -17
  74. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  75. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  76. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  77. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  78. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  79. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  80. package/src/components/atoms/TextArea/index.ts +6 -0
  81. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  82. package/src/components/atoms/index.ts +3 -0
  83. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  84. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  85. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  86. package/src/components/molecules/Accordion/index.ts +6 -0
  87. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  88. package/src/components/molecules/Animated/Animated.tsx +283 -0
  89. package/src/components/molecules/Animated/index.ts +10 -0
  90. package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +44 -25
  91. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  92. package/src/components/molecules/ButtonDock/ButtonDock.tsx +16 -13
  93. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +48 -29
  94. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  95. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  96. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  97. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  98. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  99. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  100. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  101. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  102. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  103. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  104. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  105. package/src/components/molecules/CopyField/index.ts +4 -0
  106. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  107. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  108. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  109. package/src/components/molecules/DatePicker/index.ts +2 -0
  110. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  111. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  112. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  113. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  114. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  115. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  116. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  117. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  118. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  119. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  120. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  121. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  122. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  123. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  124. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  125. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  126. package/src/components/molecules/Drawer/index.ts +12 -0
  127. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  128. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  129. package/src/components/molecules/FilterTab/index.ts +2 -0
  130. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  131. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  132. package/src/components/molecules/MessageCard/index.ts +10 -0
  133. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  134. package/src/components/molecules/Notification/Notification.tsx +426 -0
  135. package/src/components/molecules/Notification/index.ts +2 -0
  136. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  137. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  138. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  139. package/src/components/molecules/NumberField/index.ts +2 -0
  140. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  141. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  142. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  143. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +53 -0
  144. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  145. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +95 -0
  146. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  147. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  148. package/src/components/molecules/PasswordField/index.ts +10 -0
  149. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +204 -0
  150. package/src/components/molecules/PictureSelector/PictureSelector.tsx +335 -0
  151. package/src/components/molecules/PictureSelector/index.ts +5 -0
  152. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  153. package/src/components/molecules/Progress/Progress.tsx +184 -0
  154. package/src/components/molecules/Progress/index.ts +2 -0
  155. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  156. package/src/components/molecules/Radio/Radio.tsx +1 -2
  157. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  158. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  159. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  160. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  161. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  162. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  163. package/src/components/molecules/SearchField/index.ts +4 -0
  164. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  165. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  166. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  167. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  168. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  169. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  170. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  171. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  172. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  173. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  174. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  175. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  176. package/src/components/molecules/SelectField/index.ts +10 -0
  177. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  178. package/src/components/molecules/Slider/Slider.tsx +293 -180
  179. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  180. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  181. package/src/components/molecules/Tooltip/index.ts +2 -0
  182. package/src/components/molecules/index.ts +15 -0
  183. package/src/test-utils.tsx +20 -0
  184. package/tsconfig.json +1 -1
  185. package/tsup.config.ts +16 -2
  186. package/vitest.config.ts +114 -0
  187. package/vitest.setup.ts +16 -0
@@ -1,7 +1,26 @@
1
1
  import React from "react"
2
- import { View, Pressable, ScrollView, ViewProps } from "react-native"
2
+ import {
3
+ View,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ ViewProps,
8
+ NativeScrollEvent,
9
+ NativeSyntheticEvent
10
+ } from "react-native"
11
+ import Svg, {
12
+ Defs,
13
+ LinearGradient as SvgLinearGradient,
14
+ Rect,
15
+ Stop
16
+ } from "react-native-svg"
3
17
  import styled from "@emotion/native"
4
18
  import { useTheme } from "@emotion/react"
19
+ import {
20
+ KeyboardArrowLeft,
21
+ KeyboardArrowRight
22
+ } from "@butternutbox/pawprint-icons/core"
23
+ import { Icon } from "../../atoms/Icon"
5
24
  import { Typography } from "../../atoms/Typography"
6
25
 
7
26
  type SegmentedControlLayout = "fixed" | "intrinsic"
@@ -38,7 +57,6 @@ const parseTokenValue = (value: string): number => parseFloat(value)
38
57
  const StyledSegmentItem = styled(Pressable)<{
39
58
  itemHeight: number
40
59
  itemMinWidth: number
41
- itemPaddingVertical: number
42
60
  itemPaddingHorizontal: number
43
61
  itemBorderRadius: number
44
62
  itemBgColor: string
@@ -48,7 +66,6 @@ const StyledSegmentItem = styled(Pressable)<{
48
66
  ({
49
67
  itemHeight,
50
68
  itemMinWidth,
51
- itemPaddingVertical,
52
69
  itemPaddingHorizontal,
53
70
  itemBorderRadius,
54
71
  itemBgColor,
@@ -57,7 +74,6 @@ const StyledSegmentItem = styled(Pressable)<{
57
74
  }) => ({
58
75
  height: itemHeight,
59
76
  minWidth: itemMinWidth,
60
- paddingVertical: itemPaddingVertical,
61
77
  paddingHorizontal: itemPaddingHorizontal,
62
78
  borderRadius: itemBorderRadius,
63
79
  alignItems: "center",
@@ -68,6 +84,91 @@ const StyledSegmentItem = styled(Pressable)<{
68
84
  })
69
85
  )
70
86
 
87
+ const StyledIntrinsicClip = styled(View)<{
88
+ clipBorderRadius: number
89
+ clipBgColor: string
90
+ clipWidth?: number
91
+ }>(({ clipBorderRadius, clipBgColor, clipWidth }) => ({
92
+ overflow: "hidden",
93
+ borderRadius: clipBorderRadius,
94
+ backgroundColor: clipBgColor,
95
+ alignSelf: "flex-start",
96
+ maxWidth: "100%",
97
+ ...(clipWidth !== undefined ? { width: clipWidth } : {})
98
+ }))
99
+
100
+ const StyledScrollIndicator = styled(Pressable)<{
101
+ indicatorSide: "leading" | "trailing"
102
+ indicatorBorderRadius: number
103
+ indicatorPaddingOuter: number
104
+ indicatorPaddingInner: number
105
+ }>(
106
+ ({
107
+ indicatorSide,
108
+ indicatorBorderRadius,
109
+ indicatorPaddingOuter,
110
+ indicatorPaddingInner
111
+ }) => ({
112
+ position: "absolute",
113
+ top: 0,
114
+ bottom: 0,
115
+ zIndex: 2,
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ overflow: "hidden",
119
+ ...(indicatorSide === "trailing"
120
+ ? {
121
+ right: 0,
122
+ paddingLeft: indicatorPaddingOuter,
123
+ paddingRight: indicatorPaddingInner,
124
+ borderTopRightRadius: indicatorBorderRadius,
125
+ borderBottomRightRadius: indicatorBorderRadius
126
+ }
127
+ : {
128
+ left: 0,
129
+ paddingRight: indicatorPaddingOuter,
130
+ paddingLeft: indicatorPaddingInner,
131
+ borderTopLeftRadius: indicatorBorderRadius,
132
+ borderBottomLeftRadius: indicatorBorderRadius
133
+ })
134
+ })
135
+ )
136
+
137
+ const ScrollIndicatorGradient = ({
138
+ side,
139
+ color
140
+ }: {
141
+ side: "leading" | "trailing"
142
+ color: string
143
+ }) => {
144
+ const uid = React.useId()
145
+ const gradientId = `sc-scroll-indicator-${side}-${uid}`
146
+ const isTrailing = side === "trailing"
147
+
148
+ return (
149
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
150
+ <Svg width="100%" height="100%" preserveAspectRatio="none">
151
+ <Defs>
152
+ <SvgLinearGradient
153
+ id={gradientId}
154
+ x1={isTrailing ? "0" : "1"}
155
+ y1="0"
156
+ x2={isTrailing ? "1" : "0"}
157
+ y2="0"
158
+ >
159
+ <Stop offset="0" stopColor={color} stopOpacity="0" />
160
+ <Stop offset="0.25" stopColor={color} stopOpacity="0.5" />
161
+ <Stop offset="0.5" stopColor={color} stopOpacity="0.85" />
162
+ <Stop offset="0.75" stopColor={color} stopOpacity="1" />
163
+ <Stop offset="1" stopColor={color} stopOpacity="1" />
164
+ </SvgLinearGradient>
165
+ </Defs>
166
+ <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
167
+ </Svg>
168
+ </View>
169
+ )
170
+ }
171
+
71
172
  const StyledContainer = styled(View)<{
72
173
  containerGap: number
73
174
  containerPaddingVertical: number
@@ -118,6 +219,10 @@ const SegmentedControlItemBase = ({
118
219
  const { controlItem } = theme.tokens.components.segmentedControl
119
220
  const { size, spacing, borderRadius, colour, opacity } = controlItem
120
221
  const { typography } = controlItem
222
+ // Note: verticalPadding is intentionally skipped on native — the item has a
223
+ // fixed `height` and `alignItems: "center"`, which already handles vertical
224
+ // centring. Applying paddingVertical on top would clip text on RN, where
225
+ // height is strictly bounded (unlike CSS flex on web).
121
226
 
122
227
  const handlePress = () => {
123
228
  if (disabled) return
@@ -142,7 +247,6 @@ const SegmentedControlItemBase = ({
142
247
  accessibilityState={{ selected, disabled }}
143
248
  itemHeight={parseTokenValue(size.small.height)}
144
249
  itemMinWidth={parseTokenValue(size.small.minWidth)}
145
- itemPaddingVertical={parseTokenValue(spacing.verticalPadding)}
146
250
  itemPaddingHorizontal={parseTokenValue(spacing.horizontalPadding)}
147
251
  itemBorderRadius={parseTokenValue(borderRadius.default)}
148
252
  itemBgColor={selected ? colour.background.selected : "transparent"}
@@ -167,6 +271,124 @@ const SegmentedControlItem = React.forwardRef<View, SegmentedControlItemProps>(
167
271
 
168
272
  SegmentedControlItem.displayName = "SegmentedControl.Item"
169
273
 
274
+ type IntrinsicTrackProps = ViewProps & {
275
+ containerProps: {
276
+ containerGap: number
277
+ containerPaddingVertical: number
278
+ containerPaddingHorizontal: number
279
+ containerBorderRadius: number
280
+ containerBgColor: string
281
+ containerFullWidth: boolean
282
+ scrollIndicatorPaddingOuter: number
283
+ scrollIndicatorPaddingInner: number
284
+ }
285
+ renderItems: () => React.ReactNode
286
+ forwardedRef: React.Ref<View>
287
+ }
288
+
289
+ const IntrinsicTrack = ({
290
+ containerProps,
291
+ renderItems,
292
+ forwardedRef,
293
+ ...rest
294
+ }: IntrinsicTrackProps) => {
295
+ const scrollRef = React.useRef<ScrollView>(null)
296
+ const [containerWidth, setContainerWidth] = React.useState(0)
297
+ const [contentWidth, setContentWidth] = React.useState(0)
298
+ const [scrollX, setScrollX] = React.useState(0)
299
+
300
+ const epsilon = 2
301
+ const maxScroll = Math.max(0, contentWidth - containerWidth)
302
+ const canScrollLeft = scrollX > epsilon
303
+ const canScrollRight = scrollX < maxScroll - epsilon
304
+
305
+ const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
306
+ setScrollX(event.nativeEvent.contentOffset.x)
307
+ }
308
+
309
+ const scrollBy = (direction: "leading" | "trailing") => {
310
+ if (!scrollRef.current || containerWidth === 0) return
311
+ const step = containerWidth * 0.6
312
+ const target =
313
+ direction === "trailing"
314
+ ? Math.min(maxScroll, scrollX + step)
315
+ : Math.max(0, scrollX - step)
316
+ scrollRef.current.scrollTo({ x: target, animated: true })
317
+ }
318
+
319
+ return (
320
+ <View
321
+ ref={forwardedRef}
322
+ onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
323
+ {...rest}
324
+ >
325
+ <StyledIntrinsicClip
326
+ clipBorderRadius={containerProps.containerBorderRadius}
327
+ clipBgColor={containerProps.containerBgColor}
328
+ clipWidth={
329
+ containerWidth > 0 && contentWidth > 0
330
+ ? Math.min(contentWidth, containerWidth)
331
+ : undefined
332
+ }
333
+ >
334
+ <ScrollView
335
+ ref={scrollRef}
336
+ horizontal
337
+ showsHorizontalScrollIndicator={false}
338
+ onScroll={handleScroll}
339
+ scrollEventThrottle={16}
340
+ onContentSizeChange={(width) => setContentWidth(width)}
341
+ contentContainerStyle={{
342
+ flexDirection: "row",
343
+ alignItems: "stretch",
344
+ gap: containerProps.containerGap,
345
+ paddingVertical: containerProps.containerPaddingVertical,
346
+ paddingHorizontal: containerProps.containerPaddingHorizontal
347
+ }}
348
+ >
349
+ {renderItems()}
350
+ </ScrollView>
351
+ </StyledIntrinsicClip>
352
+
353
+ {canScrollLeft && (
354
+ <StyledScrollIndicator
355
+ indicatorSide="leading"
356
+ indicatorBorderRadius={containerProps.containerBorderRadius}
357
+ indicatorPaddingOuter={containerProps.scrollIndicatorPaddingOuter}
358
+ indicatorPaddingInner={containerProps.scrollIndicatorPaddingInner}
359
+ onPress={() => scrollBy("leading")}
360
+ accessibilityRole="button"
361
+ accessibilityLabel="Scroll left"
362
+ >
363
+ <ScrollIndicatorGradient
364
+ side="leading"
365
+ color={containerProps.containerBgColor}
366
+ />
367
+ <Icon icon={KeyboardArrowLeft} size="lg" colour="action-inverse" />
368
+ </StyledScrollIndicator>
369
+ )}
370
+
371
+ {canScrollRight && (
372
+ <StyledScrollIndicator
373
+ indicatorSide="trailing"
374
+ indicatorBorderRadius={containerProps.containerBorderRadius}
375
+ indicatorPaddingOuter={containerProps.scrollIndicatorPaddingOuter}
376
+ indicatorPaddingInner={containerProps.scrollIndicatorPaddingInner}
377
+ onPress={() => scrollBy("trailing")}
378
+ accessibilityRole="button"
379
+ accessibilityLabel="Scroll right"
380
+ >
381
+ <ScrollIndicatorGradient
382
+ side="trailing"
383
+ color={containerProps.containerBgColor}
384
+ />
385
+ <Icon icon={KeyboardArrowRight} size="lg" colour="action-inverse" />
386
+ </StyledScrollIndicator>
387
+ )}
388
+ </View>
389
+ )
390
+ }
391
+
170
392
  /**
171
393
  * Segmented control component for selecting exactly one option from a set.
172
394
  *
@@ -206,6 +428,7 @@ const SegmentedControlRoot = React.forwardRef<View, SegmentedControlProps>(
206
428
  const theme = useTheme()
207
429
  const { spacing, borderRadius, colour } =
208
430
  theme.tokens.components.segmentedControl
431
+ const { dimensions } = theme.tokens.semantics
209
432
 
210
433
  const optionsFirstValue = options?.[0]?.value
211
434
  const childrenArray = React.Children.toArray(children)
@@ -277,28 +500,19 @@ const SegmentedControlRoot = React.forwardRef<View, SegmentedControlProps>(
277
500
  containerPaddingHorizontal: parseTokenValue(spacing.horizontalPadding),
278
501
  containerBorderRadius: parseTokenValue(borderRadius.default),
279
502
  containerBgColor: colour.background.default,
280
- containerFullWidth: layout === "fixed"
503
+ containerFullWidth: layout === "fixed",
504
+ scrollIndicatorPaddingOuter: parseTokenValue(dimensions.spacing["2xl"]),
505
+ scrollIndicatorPaddingInner: parseTokenValue(dimensions.spacing.sm)
281
506
  }
282
507
 
283
508
  if (layout === "intrinsic") {
284
509
  return (
285
- <View ref={ref} {...rest}>
286
- <ScrollView
287
- horizontal
288
- showsHorizontalScrollIndicator={false}
289
- contentContainerStyle={{
290
- flexDirection: "row",
291
- alignItems: "stretch",
292
- gap: containerProps.containerGap,
293
- paddingVertical: containerProps.containerPaddingVertical,
294
- paddingHorizontal: containerProps.containerPaddingHorizontal,
295
- borderRadius: containerProps.containerBorderRadius,
296
- backgroundColor: containerProps.containerBgColor
297
- }}
298
- >
299
- {renderItems()}
300
- </ScrollView>
301
- </View>
510
+ <IntrinsicTrack
511
+ containerProps={containerProps}
512
+ renderItems={renderItems}
513
+ forwardedRef={ref}
514
+ {...rest}
515
+ />
302
516
  )
303
517
  }
304
518
 
@@ -0,0 +1,320 @@
1
+ import React, { useState } from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { PortalHost } from "@rn-primitives/portal"
4
+ import { SelectField, useSelectField } from "."
5
+ import { Typography } from "../../atoms/Typography"
6
+ import { Icon } from "../../atoms/Icon"
7
+ import { Search, Settings, Lock, Info } from "@butternutbox/pawprint-icons/core"
8
+ import type { Option } from "@rn-primitives/select"
9
+
10
+ const StoryWrapper = ({ children }: { children: React.ReactNode }) => (
11
+ <>
12
+ {children}
13
+ <PortalHost />
14
+ </>
15
+ )
16
+
17
+ export default {
18
+ title: "Molecules/SelectField",
19
+ component: SelectField,
20
+ parameters: {
21
+ docs: {
22
+ description: {
23
+ component:
24
+ "Select field component for choosing a value from a dropdown list. Built on @rn-primitives/select with design system styling. Supports both simple props API and compound component API."
25
+ }
26
+ }
27
+ },
28
+ argTypes: {
29
+ label: {
30
+ control: { type: "text" },
31
+ description: "Label text"
32
+ },
33
+ placeholder: {
34
+ control: { type: "text" },
35
+ description: "Placeholder text"
36
+ },
37
+ description: {
38
+ control: { type: "text" },
39
+ description: "Help text below select"
40
+ },
41
+ error: {
42
+ control: { type: "text" },
43
+ description: "Error message"
44
+ },
45
+ state: {
46
+ control: { type: "select" },
47
+ options: ["default", "error", "success"],
48
+ description: "Visual state of the select"
49
+ },
50
+ optionalText: {
51
+ control: { type: "text" },
52
+ description: "Optional text to display next to label"
53
+ },
54
+ disabled: {
55
+ control: { type: "boolean" },
56
+ description: "Disables the select"
57
+ }
58
+ }
59
+ }
60
+
61
+ export const Default = () => (
62
+ <StoryWrapper>
63
+ <View style={styles.container}>
64
+ <SelectField
65
+ label="Search Type"
66
+ placeholder="Select search type"
67
+ description="Choose how you want to search"
68
+ leadingIcon={<Icon icon={Search} size="md" />}
69
+ >
70
+ <SelectField.Item
71
+ value="products"
72
+ leadingIcon={<Icon icon={Search} size="md" />}
73
+ hintText="Search all products"
74
+ >
75
+ Products
76
+ </SelectField.Item>
77
+ <SelectField.Item
78
+ value="orders"
79
+ leadingIcon={<Icon icon={Settings} size="md" />}
80
+ hintText="Search order history"
81
+ >
82
+ Orders
83
+ </SelectField.Item>
84
+ <SelectField.Item
85
+ value="admin"
86
+ leadingIcon={<Icon icon={Lock} size="md" />}
87
+ hintText="Admin access required"
88
+ disabled
89
+ >
90
+ Admin Panel
91
+ </SelectField.Item>
92
+ <SelectField.Item
93
+ value="help"
94
+ leadingIcon={<Icon icon={Info} size="md" />}
95
+ >
96
+ Help & Support
97
+ </SelectField.Item>
98
+ </SelectField>
99
+ </View>
100
+ </StoryWrapper>
101
+ )
102
+
103
+ export const States = () => (
104
+ <StoryWrapper>
105
+ <View style={styles.column}>
106
+ <View style={styles.section}>
107
+ <Typography size="sm" weight="semiBold" color="tertiary">
108
+ Default State
109
+ </Typography>
110
+ <SelectField
111
+ label="Default State"
112
+ placeholder="Select an option"
113
+ description="Normal select state"
114
+ >
115
+ <SelectField.Item value="option1">Option 1</SelectField.Item>
116
+ <SelectField.Item value="option2">Option 2</SelectField.Item>
117
+ <SelectField.Item value="option3">Option 3</SelectField.Item>
118
+ </SelectField>
119
+ </View>
120
+
121
+ <View style={styles.section}>
122
+ <Typography size="sm" weight="semiBold" color="tertiary">
123
+ Error State
124
+ </Typography>
125
+ <SelectField
126
+ label="Error State"
127
+ placeholder="Select an option"
128
+ state="error"
129
+ error="This field is required"
130
+ description="Error state"
131
+ >
132
+ <SelectField.Item value="option1">Option 1</SelectField.Item>
133
+ <SelectField.Item value="option2">Option 2</SelectField.Item>
134
+ </SelectField>
135
+ </View>
136
+
137
+ <View style={styles.section}>
138
+ <Typography size="sm" weight="semiBold" color="tertiary">
139
+ Success State
140
+ </Typography>
141
+ <SelectField
142
+ label="Success State"
143
+ placeholder="Select an option"
144
+ state="success"
145
+ description="Success state"
146
+ defaultValue="option1"
147
+ >
148
+ <SelectField.Item value="option1">Option 1</SelectField.Item>
149
+ <SelectField.Item value="option2">Option 2</SelectField.Item>
150
+ </SelectField>
151
+ </View>
152
+
153
+ <View style={styles.section}>
154
+ <Typography size="sm" weight="semiBold" color="tertiary">
155
+ Disabled
156
+ </Typography>
157
+ <SelectField
158
+ label="Disabled"
159
+ placeholder="Select an option"
160
+ description="Disabled select field"
161
+ disabled
162
+ >
163
+ <SelectField.Item value="option1">Option 1</SelectField.Item>
164
+ <SelectField.Item value="option2">Option 2</SelectField.Item>
165
+ </SelectField>
166
+ </View>
167
+ </View>
168
+ </StoryWrapper>
169
+ )
170
+
171
+ export const WithOptionalText = () => (
172
+ <StoryWrapper>
173
+ <View style={styles.column}>
174
+ <View style={styles.section}>
175
+ <Typography size="sm" weight="semiBold" color="tertiary">
176
+ With Optional Text
177
+ </Typography>
178
+ <SelectField
179
+ label="Country"
180
+ optionalText="(optional)"
181
+ placeholder="Select a country"
182
+ description="Optional select field"
183
+ >
184
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
185
+ <SelectField.Item value="us">United States</SelectField.Item>
186
+ <SelectField.Item value="ca">Canada</SelectField.Item>
187
+ </SelectField>
188
+ </View>
189
+ </View>
190
+ </StoryWrapper>
191
+ )
192
+
193
+ export const CustomValidation = () => {
194
+ const [value, setValue] = useState<Option | string | null>(null)
195
+
196
+ // Extract actual value for validation
197
+ const actualValue =
198
+ value && typeof value === "object" && "value" in value ? value.value : value
199
+
200
+ return (
201
+ <StoryWrapper>
202
+ <View style={styles.column}>
203
+ <View style={styles.section}>
204
+ <Typography size="sm" weight="semiBold" color="tertiary">
205
+ Custom Validation
206
+ </Typography>
207
+ <SelectField
208
+ label="Product Category"
209
+ placeholder="Select category"
210
+ value={value}
211
+ onValueChange={(newValue) => setValue(newValue)}
212
+ description="Select a valid category"
213
+ error={
214
+ actualValue === "invalid"
215
+ ? "This option is not allowed"
216
+ : undefined
217
+ }
218
+ state={actualValue === "invalid" ? "error" : "default"}
219
+ >
220
+ <SelectField.Item value="food">Food</SelectField.Item>
221
+ <SelectField.Item value="toys">Toys</SelectField.Item>
222
+ <SelectField.Item value="invalid">Invalid Option</SelectField.Item>
223
+ </SelectField>
224
+ </View>
225
+ </View>
226
+ </StoryWrapper>
227
+ )
228
+ }
229
+
230
+ export const CustomStateValidationWithSuccess = () => {
231
+ const selectProps = useSelectField({
232
+ validationRule: {
233
+ test: (value) => {
234
+ if (!value) return false
235
+ // Handle object with value property
236
+ const actualValue =
237
+ typeof value === "object" && value !== null && "value" in value
238
+ ? (value as { value: string }).value
239
+ : value
240
+ return actualValue !== "invalid"
241
+ },
242
+ message: "Please select a valid option"
243
+ }
244
+ })
245
+
246
+ return (
247
+ <StoryWrapper>
248
+ <View style={styles.column}>
249
+ <View style={styles.section}>
250
+ <Typography size="sm" weight="semiBold" color="tertiary">
251
+ Real-time Validation with Success State
252
+ </Typography>
253
+ <SelectField
254
+ {...selectProps}
255
+ label="Product Category"
256
+ placeholder="Select category"
257
+ description="Select a valid category (not 'Invalid Option')"
258
+ >
259
+ <SelectField.Item value="food">Food</SelectField.Item>
260
+ <SelectField.Item value="toys">Toys</SelectField.Item>
261
+ <SelectField.Item value="accessories">Accessories</SelectField.Item>
262
+ <SelectField.Item value="invalid">Invalid Option</SelectField.Item>
263
+ </SelectField>
264
+ </View>
265
+ </View>
266
+ </StoryWrapper>
267
+ )
268
+ }
269
+
270
+ export const Controlled = () => {
271
+ const selectProps = useSelectField()
272
+
273
+ // Extract the actual value for display (handle object with value property)
274
+ const displayValue = (() => {
275
+ const val = selectProps.value
276
+ if (!val) return "(none)"
277
+ if (typeof val === "object" && val !== null && "value" in val) {
278
+ return (val as { value: string }).value
279
+ }
280
+ return String(val)
281
+ })()
282
+
283
+ return (
284
+ <StoryWrapper>
285
+ <View style={styles.column}>
286
+ <View style={styles.section}>
287
+ <Typography size="sm" weight="semiBold" color="tertiary">
288
+ Controlled: {displayValue}
289
+ </Typography>
290
+ <SelectField
291
+ {...selectProps}
292
+ label="Country"
293
+ placeholder="Select a country"
294
+ description={`Current value: "${displayValue}"`}
295
+ >
296
+ <SelectField.Item value="uk">United Kingdom</SelectField.Item>
297
+ <SelectField.Item value="us">United States</SelectField.Item>
298
+ <SelectField.Item value="ca">Canada</SelectField.Item>
299
+ <SelectField.Item value="au">Australia</SelectField.Item>
300
+ </SelectField>
301
+ </View>
302
+ </View>
303
+ </StoryWrapper>
304
+ )
305
+ }
306
+
307
+ const styles = StyleSheet.create({
308
+ container: {
309
+ width: 320
310
+ },
311
+ column: {
312
+ flexDirection: "column",
313
+ gap: 24,
314
+ width: 320
315
+ },
316
+ section: {
317
+ flexDirection: "column",
318
+ gap: 8
319
+ }
320
+ })