@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
@@ -0,0 +1,335 @@
1
+ import React from "react"
2
+ import { View, Pressable, ViewProps, PressableProps } from "react-native"
3
+ import Svg, { Path } from "react-native-svg"
4
+ import styled from "@emotion/native"
5
+ import { useTheme } from "@emotion/react"
6
+ import { Illustration } from "../../atoms/Illustration"
7
+ import { Typography } from "../../atoms/Typography"
8
+ import { MessageCard } from "../MessageCard"
9
+
10
+ // ─── Types ────────────────────────────────────────────────────────────────────
11
+
12
+ type PawprintIllustration = React.ComponentType<{
13
+ width?: number
14
+ height?: number
15
+ }>
16
+
17
+ export type PictureSelectorItem = {
18
+ value: string
19
+ illustration?: PawprintIllustration
20
+ disabled?: boolean
21
+ ariaLabel?: string
22
+ insightTitle?: string
23
+ insightDescription?: React.ReactNode
24
+ }
25
+
26
+ type PictureSelectorOwnProps = {
27
+ label?: string
28
+ items: PictureSelectorItem[]
29
+ value?: string
30
+ defaultValue?: string
31
+ onValueChange?: (value: string) => void
32
+ /** Optional fixed card width (px). Defaults to `100%` (fluid). */
33
+ itemWidth?: number
34
+ /** Card aspect ratio (width / height). Defaults to `1` (square). */
35
+ itemAspectRatio?: number
36
+ /** Optional floor on each card's width (px). No default. */
37
+ itemMinWidth?: number
38
+ /** Optional floor on each card's height (px). No default. */
39
+ itemMinHeight?: number
40
+ }
41
+
42
+ export type PictureSelectorProps = PictureSelectorOwnProps &
43
+ Omit<ViewProps, keyof PictureSelectorOwnProps>
44
+
45
+ type PressableOwnForItem = Omit<PressableProps, "onPress" | "disabled">
46
+
47
+ // ─── Pointer shape ────────────────────────────────────────────────────────────
48
+
49
+ // TODO: add to pictureSelector.json — pointer is 24w × 12h triangle in Figma.
50
+ const POINTER_WIDTH = 24
51
+ const POINTER_HEIGHT = 12
52
+
53
+ // Build an SVG path for the rounded-corner pointer: apex centered at the top
54
+ // of a 24×12 box, bottom edge along the bottom. `r` is the corner radius. The
55
+ // triangle is isoceles with apex interior angle 90° and bottom interior angles
56
+ // 45°, so the inset distances along each edge from a vertex are r·cot(θ/2) —
57
+ // i.e. r at the apex and r·(√2 + 1) at the bottom corners.
58
+ const buildPointerPath = (r: number): string => {
59
+ const w = POINTER_WIDTH
60
+ const h = POINTER_HEIGHT
61
+ const apexInset = r
62
+ const botInset = r * (Math.SQRT2 + 1)
63
+ const sin45 = Math.SQRT1_2
64
+ const apexAxis = apexInset * sin45
65
+ const botAxis = botInset * sin45
66
+
67
+ const apexLeftX = w / 2 - apexAxis
68
+ const apexRightX = w / 2 + apexAxis
69
+ const apexY = apexAxis
70
+ const blEdgeX = botAxis
71
+ const blEdgeY = h - botAxis
72
+ const blBottomX = botInset
73
+ const brBottomX = w - botInset
74
+ const brEdgeX = w - botAxis
75
+
76
+ return [
77
+ `M ${blEdgeX} ${blEdgeY}`,
78
+ `L ${apexLeftX} ${apexY}`,
79
+ `A ${r} ${r} 0 0 1 ${apexRightX} ${apexY}`,
80
+ `L ${brEdgeX} ${blEdgeY}`,
81
+ `A ${r} ${r} 0 0 1 ${brBottomX} ${h}`,
82
+ `L ${blBottomX} ${h}`,
83
+ `A ${r} ${r} 0 0 1 ${blEdgeX} ${blEdgeY}`,
84
+ "Z"
85
+ ].join(" ")
86
+ }
87
+
88
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
89
+
90
+ const parseTokenValue = (value: string): number => parseFloat(value)
91
+
92
+ // ─── Styled primitives ────────────────────────────────────────────────────────
93
+
94
+ const StyledRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
95
+ flexDirection: "column",
96
+ gap: rootGap,
97
+ width: "100%"
98
+ }))
99
+
100
+ const StyledRow = styled(View)<{ rowGap: number }>(({ rowGap }) => ({
101
+ flexDirection: "row",
102
+ alignItems: "flex-start",
103
+ gap: rowGap,
104
+ width: "100%"
105
+ }))
106
+
107
+ const StyledItemStack = styled(View)<{ stackGap: number }>(({ stackGap }) => ({
108
+ flexDirection: "column",
109
+ alignItems: "center",
110
+ gap: stackGap,
111
+ flex: 1,
112
+ minWidth: 0
113
+ }))
114
+
115
+ const StyledPictureButton = styled(Pressable)<{
116
+ buttonBorderRadius: number
117
+ buttonBorderWidth: number
118
+ buttonBorderColor: string
119
+ buttonBgColor: string
120
+ buttonPadding: number
121
+ buttonOpacity: number
122
+ buttonWidth?: number
123
+ buttonAspectRatio: number
124
+ buttonMinWidth?: number
125
+ buttonMinHeight?: number
126
+ }>(
127
+ ({
128
+ buttonBorderRadius,
129
+ buttonBorderWidth,
130
+ buttonBorderColor,
131
+ buttonBgColor,
132
+ buttonPadding,
133
+ buttonOpacity,
134
+ buttonWidth,
135
+ buttonAspectRatio,
136
+ buttonMinWidth,
137
+ buttonMinHeight
138
+ }) => ({
139
+ alignItems: "center",
140
+ justifyContent: "center",
141
+ width: buttonWidth !== undefined ? buttonWidth : "100%",
142
+ aspectRatio: buttonAspectRatio,
143
+ ...(buttonMinWidth !== undefined && { minWidth: buttonMinWidth }),
144
+ ...(buttonMinHeight !== undefined && { minHeight: buttonMinHeight }),
145
+ padding: buttonPadding,
146
+ borderRadius: buttonBorderRadius,
147
+ borderWidth: buttonBorderWidth,
148
+ borderColor: buttonBorderColor,
149
+ backgroundColor: buttonBgColor,
150
+ opacity: buttonOpacity,
151
+ overflow: "hidden"
152
+ })
153
+ )
154
+
155
+ const StyledPointer = styled(Svg)<{ pointerVisible: boolean }>(
156
+ ({ pointerVisible }) => ({
157
+ width: POINTER_WIDTH,
158
+ height: POINTER_HEIGHT,
159
+ opacity: pointerVisible ? 1 : 0
160
+ })
161
+ )
162
+
163
+ // ─── Component ────────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * A horizontal group of image/illustration buttons where one is selectable at
167
+ * a time. When the selected item has `insightTitle`/`insightDescription`, a
168
+ * `MessageCard.Insight` (standalone variant) renders below the row with a
169
+ * pointer beneath the selected button linking the two. The pointer moves with
170
+ * the selection.
171
+ *
172
+ * By default each card fills the width of its flex item (`width: 100%`)
173
+ * and is square (`aspectRatio: 1`). Override with `itemWidth` for a fixed
174
+ * width, `itemAspectRatio` for a non-square shape, and `itemMinWidth` /
175
+ * `itemMinHeight` for per-dim minimum floors.
176
+ *
177
+ * @param {string} [label] - Optional headline above the row.
178
+ * @param {PictureSelectorItem[]} items - 3 or 4 items to choose between.
179
+ * @param {string} [value] - Controlled selected value.
180
+ * @param {string} [defaultValue] - Uncontrolled initial selected value.
181
+ * @param {(value: string) => void} [onValueChange] - Fires when selection changes.
182
+ * @param {number} [itemWidth] - Fixed card width (px). Defaults to `100%`.
183
+ * @param {number} [itemAspectRatio=1] - Card aspect ratio (width / height).
184
+ * @param {number} [itemMinWidth] - Optional override for the card's min-width.
185
+ * Defaults to `pictureButton.size.minWidth` (80px).
186
+ * @param {number} [itemMinHeight] - Optional override for the card's min-height.
187
+ * Defaults to `pictureButton.size.minHeight` (120px).
188
+ */
189
+ export const PictureSelector = React.forwardRef<View, PictureSelectorProps>(
190
+ (
191
+ {
192
+ label,
193
+ items,
194
+ value,
195
+ defaultValue,
196
+ onValueChange,
197
+ itemWidth,
198
+ itemAspectRatio = 1,
199
+ itemMinWidth,
200
+ itemMinHeight,
201
+ ...rest
202
+ },
203
+ ref
204
+ ) => {
205
+ const theme = useTheme()
206
+ const { pictureSelector } = theme.tokens.components
207
+ const { pictureButton } = pictureSelector
208
+
209
+ const isControlled = value !== undefined
210
+ const [internalValue, setInternalValue] = React.useState<
211
+ string | undefined
212
+ >(defaultValue)
213
+ const selectedValue = isControlled ? value : internalValue
214
+ const selectedItem = items.find((item) => item.value === selectedValue)
215
+ const hasInsight = Boolean(
216
+ selectedItem?.insightTitle || selectedItem?.insightDescription
217
+ )
218
+
219
+ const handleSelect = (nextValue: string) => {
220
+ if (!isControlled) setInternalValue(nextValue)
221
+ onValueChange?.(nextValue)
222
+ }
223
+
224
+ const rootGap = parseTokenValue(pictureSelector.spacing.container.gap)
225
+ const rowGap = parseTokenValue(pictureSelector.spacing.buttons.gap)
226
+ const stackGap = parseTokenValue(pictureButton.spacing.container.gap)
227
+ const buttonPadding = parseTokenValue(pictureButton.spacing.padding)
228
+ const buttonBorderRadius = parseTokenValue(
229
+ pictureButton.borderRadius.default
230
+ )
231
+ const borderWidthDefault = parseTokenValue(
232
+ pictureButton.borderWidth.default
233
+ )
234
+ const borderWidthActive = parseTokenValue(pictureButton.borderWidth.active)
235
+ const pointerPath = buildPointerPath(
236
+ parseTokenValue(pictureButton.pointer.borderRadius.default)
237
+ )
238
+ const resolvedMinWidth =
239
+ itemMinWidth ?? parseTokenValue(pictureButton.size.minWidth)
240
+ // Default min-height to min-width so the cards stay square at the floor,
241
+ // regardless of how many items are in the row.
242
+ const resolvedMinHeight = itemMinHeight ?? resolvedMinWidth
243
+
244
+ return (
245
+ <StyledRoot
246
+ ref={ref}
247
+ rootGap={rootGap}
248
+ accessible
249
+ accessibilityRole="radiogroup"
250
+ accessibilityLabel={label}
251
+ {...rest}
252
+ >
253
+ {label && (
254
+ <Typography
255
+ token={pictureSelector.typography.label}
256
+ color={pictureSelector.colour.text.default}
257
+ >
258
+ {label}
259
+ </Typography>
260
+ )}
261
+ <StyledRow rowGap={rowGap}>
262
+ {items.map((item) => {
263
+ const isSelected = item.value === selectedValue
264
+ const Visual = item.illustration
265
+ const buttonProps: Omit<
266
+ React.ComponentProps<typeof StyledPictureButton>,
267
+ keyof PressableOwnForItem
268
+ > = {
269
+ buttonBorderRadius,
270
+ buttonBorderWidth: isSelected
271
+ ? borderWidthActive
272
+ : borderWidthDefault,
273
+ buttonBorderColor: isSelected
274
+ ? pictureButton.colour.border.active
275
+ : pictureButton.colour.border.default,
276
+ buttonBgColor: isSelected
277
+ ? pictureButton.colour.background.active
278
+ : pictureButton.colour.background.default,
279
+ buttonPadding,
280
+ buttonOpacity: item.disabled ? 0.5 : 1,
281
+ buttonWidth: itemWidth,
282
+ buttonAspectRatio: itemAspectRatio,
283
+ buttonMinWidth: resolvedMinWidth,
284
+ buttonMinHeight: resolvedMinHeight
285
+ }
286
+
287
+ return (
288
+ <StyledItemStack key={item.value} stackGap={stackGap}>
289
+ <StyledPictureButton
290
+ accessible
291
+ accessibilityRole="radio"
292
+ accessibilityState={{
293
+ selected: isSelected,
294
+ disabled: item.disabled
295
+ }}
296
+ accessibilityLabel={item.ariaLabel}
297
+ disabled={item.disabled}
298
+ onPress={() => {
299
+ if (!item.disabled) handleSelect(item.value)
300
+ }}
301
+ {...buttonProps}
302
+ >
303
+ {Visual ? (
304
+ <Illustration illustration={Visual} size="xl" />
305
+ ) : null}
306
+ </StyledPictureButton>
307
+ <StyledPointer
308
+ pointerVisible={isSelected && hasInsight}
309
+ width={POINTER_WIDTH}
310
+ height={POINTER_HEIGHT}
311
+ viewBox={`0 0 ${POINTER_WIDTH} ${POINTER_HEIGHT}`}
312
+ >
313
+ <Path
314
+ d={pointerPath}
315
+ fill={pictureButton.colour.pointer.default}
316
+ />
317
+ </StyledPointer>
318
+ </StyledItemStack>
319
+ )
320
+ })}
321
+ </StyledRow>
322
+ {hasInsight && selectedItem && (
323
+ <MessageCard.Insight
324
+ variant="standalone"
325
+ title={selectedItem.insightTitle}
326
+ >
327
+ {selectedItem.insightDescription}
328
+ </MessageCard.Insight>
329
+ )}
330
+ </StyledRoot>
331
+ )
332
+ }
333
+ )
334
+
335
+ PictureSelector.displayName = "PictureSelector"
@@ -0,0 +1,5 @@
1
+ export { PictureSelector } from "./PictureSelector"
2
+ export type {
3
+ PictureSelectorProps,
4
+ PictureSelectorItem
5
+ } from "./PictureSelector"
@@ -0,0 +1,145 @@
1
+ import React from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { Progress } from "./Progress"
4
+ import type { ProgressProps } from "./Progress"
5
+ import { Typography } from "../../atoms/Typography"
6
+
7
+ export default {
8
+ title: "Molecules/Progress",
9
+ component: Progress,
10
+ argTypes: {
11
+ value: {
12
+ control: { type: "range", min: 0, max: 100, step: 1 },
13
+ description: "Current progress value (0 to max)"
14
+ },
15
+ max: {
16
+ control: { type: "number" },
17
+ description: "Maximum value representing 100% completion"
18
+ },
19
+ size: {
20
+ control: { type: "select" },
21
+ options: ["sm", "lg"],
22
+ description: "Track height (sm: 8px, lg: 16px)"
23
+ },
24
+ colour: {
25
+ control: { type: "select" },
26
+ options: ["primary", "secondary"],
27
+ description: "Indicator fill colour"
28
+ },
29
+ label: {
30
+ control: { type: "text" },
31
+ description: "Label rendered above the track (left)"
32
+ },
33
+ description: {
34
+ control: { type: "text" },
35
+ description: "Helper text rendered above the track (right)"
36
+ }
37
+ }
38
+ }
39
+
40
+ export const Playground = {
41
+ args: {
42
+ value: 50,
43
+ max: 100,
44
+ size: "sm",
45
+ colour: "primary",
46
+ label: "Label",
47
+ description: "Help text"
48
+ },
49
+ render: (args: ProgressProps) => (
50
+ <View style={styles.container}>
51
+ <Progress {...args} />
52
+ </View>
53
+ )
54
+ }
55
+
56
+ export const Small = {
57
+ name: "Small",
58
+ render: () => (
59
+ <View style={styles.column}>
60
+ <Progress size="sm" value={0} label="Label" description="0%" />
61
+ <Progress size="sm" value={30} label="Label" description="30%" />
62
+ <Progress size="sm" value={60} label="Label" description="60%" />
63
+ <Progress size="sm" value={100} label="Label" description="100%" />
64
+ </View>
65
+ )
66
+ }
67
+
68
+ export const Large = {
69
+ name: "Large",
70
+ render: () => (
71
+ <View style={styles.column}>
72
+ <Progress size="lg" value={0} label="Label" description="0%" />
73
+ <Progress size="lg" value={30} label="Label" description="30%" />
74
+ <Progress size="lg" value={60} label="Label" description="60%" />
75
+ <Progress size="lg" value={100} label="Label" description="100%" />
76
+ </View>
77
+ )
78
+ }
79
+
80
+ export const Colours = {
81
+ name: "Colours",
82
+ render: () => (
83
+ <View style={styles.column}>
84
+ <View style={styles.section}>
85
+ <Typography size="sm" weight="semiBold" color="tertiary">
86
+ Primary (Yellow)
87
+ </Typography>
88
+ <Progress value={60} colour="primary" />
89
+ </View>
90
+ <View style={styles.section}>
91
+ <Typography size="sm" weight="semiBold" color="tertiary">
92
+ Secondary (Brown)
93
+ </Typography>
94
+ <Progress value={60} colour="secondary" />
95
+ </View>
96
+ </View>
97
+ )
98
+ }
99
+
100
+ export const WithoutHeader = {
101
+ name: "Without Header",
102
+ render: () => (
103
+ <View style={styles.column}>
104
+ <Progress size="sm" value={40} />
105
+ <Progress size="lg" value={70} />
106
+ </View>
107
+ )
108
+ }
109
+
110
+ export const FractionalValue = {
111
+ name: "Fractional Value",
112
+ render: () => (
113
+ <View style={styles.column}>
114
+ <Progress
115
+ size="lg"
116
+ value={14}
117
+ max={14}
118
+ label="Box capacity"
119
+ description="14/14"
120
+ />
121
+ <Progress
122
+ size="lg"
123
+ value={9}
124
+ max={14}
125
+ label="Box capacity"
126
+ description="9/14"
127
+ />
128
+ </View>
129
+ )
130
+ }
131
+
132
+ const styles = StyleSheet.create({
133
+ container: {
134
+ padding: 16
135
+ },
136
+ column: {
137
+ padding: 16,
138
+ flexDirection: "column",
139
+ gap: 16
140
+ },
141
+ section: {
142
+ flexDirection: "column",
143
+ gap: 8
144
+ }
145
+ })
@@ -0,0 +1,184 @@
1
+ import React from "react"
2
+ import { View, ViewProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import * as ProgressPrimitive from "@rn-primitives/progress"
6
+ import { Typography } from "../../atoms/Typography"
7
+
8
+ type ProgressSize = "sm" | "lg"
9
+ type ProgressColour = "primary" | "secondary"
10
+
11
+ type ProgressOwnProps = {
12
+ value?: number
13
+ max?: number
14
+ size?: ProgressSize
15
+ colour?: ProgressColour
16
+ label?: React.ReactNode
17
+ description?: React.ReactNode
18
+ getValueLabel?: (value: number, max: number) => string
19
+ }
20
+
21
+ export type ProgressProps = ProgressOwnProps &
22
+ Omit<ViewProps, keyof ProgressOwnProps>
23
+
24
+ const parseTokenValue = (value: string): number => parseFloat(value)
25
+
26
+ const StyledRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
27
+ flexDirection: "column",
28
+ alignItems: "stretch",
29
+ gap: rootGap,
30
+ width: "100%"
31
+ }))
32
+
33
+ const StyledHeader = styled(View)({
34
+ flexDirection: "row",
35
+ alignItems: "center",
36
+ justifyContent: "space-between",
37
+ width: "100%"
38
+ })
39
+
40
+ const StyledTrack = styled(View)<{
41
+ trackHeight: number
42
+ trackMinWidth: number
43
+ trackBorderRadius: number
44
+ trackBgColor: string
45
+ }>(({ trackHeight, trackMinWidth, trackBorderRadius, trackBgColor }) => ({
46
+ position: "relative",
47
+ width: "100%",
48
+ minWidth: trackMinWidth,
49
+ height: trackHeight,
50
+ borderRadius: trackBorderRadius,
51
+ backgroundColor: trackBgColor,
52
+ overflow: "hidden"
53
+ }))
54
+
55
+ const StyledIndicator = styled(View)<{
56
+ indicatorBorderRadius: number
57
+ indicatorBgColor: string
58
+ }>(({ indicatorBorderRadius, indicatorBgColor }) => ({
59
+ height: "100%",
60
+ borderRadius: indicatorBorderRadius,
61
+ backgroundColor: indicatorBgColor
62
+ }))
63
+
64
+ const clamp = (value: number, min: number, max: number) =>
65
+ Math.max(min, Math.min(max, value))
66
+
67
+ /**
68
+ * A horizontal progress bar that visualises the completion of a determinate
69
+ * task. Supports an optional label (top-left) and description (top-right),
70
+ * two sizes, and two indicator colours.
71
+ *
72
+ * Wraps `@rn-primitives/progress` for accessibility.
73
+ *
74
+ * @param {number} [value=0] - Current progress value (0 to `max`). Clamped.
75
+ * @param {number} [max=100] - Maximum value representing 100% completion.
76
+ * @param {"sm" | "lg"} [size="sm"] - Track height: sm (8px) or lg (16px).
77
+ * @param {"primary" | "secondary"} [colour="primary"] - Indicator fill colour.
78
+ * @param {React.ReactNode} [label] - Label rendered in the top-left above the track.
79
+ * @param {React.ReactNode} [description] - Helper text rendered in the top-right above the track.
80
+ * @param {(value: number, max: number) => string} [getValueLabel] - Accessible value label.
81
+ *
82
+ * @example
83
+ * ```tsx
84
+ * import { Progress } from "@butternutbox/pawprint-native"
85
+ *
86
+ * <Progress label="Box capacity" description="14/14" value={100} />
87
+ * ```
88
+ */
89
+ export const Progress = React.forwardRef<View, ProgressProps>(
90
+ (
91
+ {
92
+ value = 0,
93
+ max = 100,
94
+ size = "sm",
95
+ colour = "primary",
96
+ label,
97
+ description,
98
+ getValueLabel,
99
+ ...rest
100
+ },
101
+ ref
102
+ ) => {
103
+ const theme = useTheme()
104
+ const {
105
+ track,
106
+ indicator,
107
+ borderRadius,
108
+ spacing,
109
+ typography,
110
+ colour: textColour
111
+ } = theme.tokens.components.progress
112
+
113
+ const safeMax = max > 0 ? max : 100
114
+ const clampedValue = clamp(value, 0, safeMax)
115
+ const percent = (clampedValue / safeMax) * 100
116
+ const showHeader = Boolean(label) || Boolean(description)
117
+ const typographyForSize =
118
+ size === "lg" ? typography.large : typography.small
119
+
120
+ const trackSize = { lg: track.size.large, sm: track.size.small }[size]
121
+ const trackHeight = parseTokenValue(trackSize.height)
122
+ const trackMinWidth = parseTokenValue(trackSize.mindWidth)
123
+ const trackBorderRadius = parseTokenValue(borderRadius.track.default)
124
+ const indicatorBorderRadius = parseTokenValue(
125
+ borderRadius.indicator.default
126
+ )
127
+ const indicatorBgColor =
128
+ colour === "secondary"
129
+ ? indicator.colour.background.secondary
130
+ : indicator.colour.background.primary
131
+
132
+ return (
133
+ <StyledRoot ref={ref} rootGap={parseTokenValue(spacing.gap)} {...rest}>
134
+ {showHeader && (
135
+ <StyledHeader>
136
+ {label ? (
137
+ <Typography
138
+ token={typographyForSize.label}
139
+ color={textColour.text.label}
140
+ >
141
+ {label}
142
+ </Typography>
143
+ ) : (
144
+ <View />
145
+ )}
146
+ {description ? (
147
+ <Typography
148
+ token={typographyForSize.description}
149
+ color={textColour.text.helper}
150
+ >
151
+ {description}
152
+ </Typography>
153
+ ) : (
154
+ <View />
155
+ )}
156
+ </StyledHeader>
157
+ )}
158
+ <ProgressPrimitive.Root
159
+ value={clampedValue}
160
+ max={safeMax}
161
+ getValueLabel={getValueLabel}
162
+ asChild
163
+ >
164
+ <StyledTrack
165
+ trackHeight={trackHeight}
166
+ trackMinWidth={trackMinWidth}
167
+ trackBorderRadius={trackBorderRadius}
168
+ trackBgColor={track.colour.background.default}
169
+ >
170
+ <ProgressPrimitive.Indicator asChild>
171
+ <StyledIndicator
172
+ indicatorBorderRadius={indicatorBorderRadius}
173
+ indicatorBgColor={indicatorBgColor}
174
+ style={{ width: `${percent}%` }}
175
+ />
176
+ </ProgressPrimitive.Indicator>
177
+ </StyledTrack>
178
+ </ProgressPrimitive.Root>
179
+ </StyledRoot>
180
+ )
181
+ }
182
+ )
183
+
184
+ Progress.displayName = "Progress"
@@ -0,0 +1,2 @@
1
+ export { Progress } from "./Progress"
2
+ export type { ProgressProps } from "./Progress"