@butternutbox/pawprint-native 0.0.1 → 0.1.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 (182) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +16 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12370 -1455
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1110 -11
  7. package/dist/index.d.ts +1110 -11
  8. package/dist/index.js +12324 -1455
  9. package/dist/index.js.map +1 -1
  10. package/package.json +28 -9
  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.test.tsx +55 -0
  42. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  43. package/src/components/atoms/Input/Input.test.tsx +306 -0
  44. package/src/components/atoms/Input/Input.tsx +9 -1
  45. package/src/components/atoms/Input/InputField.tsx +226 -74
  46. package/src/components/atoms/Link/Link.test.tsx +89 -0
  47. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  48. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  49. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  50. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  51. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  52. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  53. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  54. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  55. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  56. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  57. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  58. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  61. package/src/components/atoms/Logo/assets/index.ts +11 -0
  62. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  63. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  64. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  65. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  66. package/src/components/atoms/NumberInput/index.ts +4 -0
  67. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  68. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  69. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  70. package/src/components/atoms/Switch/Switch.tsx +16 -13
  71. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  72. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  73. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  74. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  75. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  76. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  77. package/src/components/atoms/TextArea/index.ts +6 -0
  78. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  79. package/src/components/atoms/index.ts +3 -0
  80. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  81. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  82. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  83. package/src/components/molecules/Accordion/index.ts +6 -0
  84. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  85. package/src/components/molecules/Animated/Animated.tsx +283 -0
  86. package/src/components/molecules/Animated/index.ts +10 -0
  87. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  88. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
  89. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  90. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  91. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  92. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  93. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  94. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  95. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  96. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  97. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  98. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  99. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  100. package/src/components/molecules/CopyField/index.ts +4 -0
  101. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  102. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  103. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  104. package/src/components/molecules/DatePicker/index.ts +2 -0
  105. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  106. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  107. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  108. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  109. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  110. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  111. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  112. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  113. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  114. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  115. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  116. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  117. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  118. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  119. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  120. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  121. package/src/components/molecules/Drawer/index.ts +12 -0
  122. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  123. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  124. package/src/components/molecules/FilterTab/index.ts +2 -0
  125. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  126. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  127. package/src/components/molecules/MessageCard/index.ts +10 -0
  128. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  129. package/src/components/molecules/Notification/Notification.tsx +426 -0
  130. package/src/components/molecules/Notification/index.ts +2 -0
  131. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  132. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  133. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  134. package/src/components/molecules/NumberField/index.ts +2 -0
  135. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  136. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  137. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  138. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
  139. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  140. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
  141. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  142. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  143. package/src/components/molecules/PasswordField/index.ts +10 -0
  144. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
  145. package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
  146. package/src/components/molecules/PictureSelector/index.ts +5 -0
  147. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  148. package/src/components/molecules/Progress/Progress.tsx +184 -0
  149. package/src/components/molecules/Progress/index.ts +2 -0
  150. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  151. package/src/components/molecules/Radio/Radio.tsx +1 -2
  152. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  153. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  154. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  155. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  156. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  157. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  158. package/src/components/molecules/SearchField/index.ts +4 -0
  159. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  160. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  161. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  162. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  163. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  164. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  165. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  166. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  167. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  168. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  169. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  170. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  171. package/src/components/molecules/SelectField/index.ts +10 -0
  172. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  173. package/src/components/molecules/Slider/Slider.tsx +293 -180
  174. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  175. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  176. package/src/components/molecules/Tooltip/index.ts +2 -0
  177. package/src/components/molecules/index.ts +15 -0
  178. package/src/test-utils.tsx +20 -0
  179. package/tsconfig.json +1 -1
  180. package/tsup.config.ts +16 -2
  181. package/vitest.config.ts +114 -0
  182. package/vitest.setup.ts +16 -0
@@ -0,0 +1,313 @@
1
+ import React from "react"
2
+ import { View, Pressable, ViewProps, PressableProps } from "react-native"
3
+ import styled from "@emotion/native"
4
+ import { useTheme } from "@emotion/react"
5
+ import { Icon, type PawprintIcon } from "../../atoms/Icon"
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
+ icon?: PawprintIcon
21
+ disabled?: boolean
22
+ ariaLabel?: string
23
+ insightTitle?: string
24
+ insightDescription?: React.ReactNode
25
+ }
26
+
27
+ type PictureSelectorOwnProps = {
28
+ label?: string
29
+ items: PictureSelectorItem[]
30
+ value?: string
31
+ defaultValue?: string
32
+ onValueChange?: (value: string) => void
33
+ /** Optional fixed card width (px). Defaults to `100%` (fluid). */
34
+ itemWidth?: number
35
+ /** Card aspect ratio (width / height). Defaults to `1` (square). */
36
+ itemAspectRatio?: number
37
+ /** Optional floor on each card's width (px). No default. */
38
+ itemMinWidth?: number
39
+ /** Optional floor on each card's height (px). No default. */
40
+ itemMinHeight?: number
41
+ }
42
+
43
+ export type PictureSelectorProps = PictureSelectorOwnProps &
44
+ Omit<ViewProps, keyof PictureSelectorOwnProps>
45
+
46
+ type PressableOwnForItem = Omit<PressableProps, "onPress" | "disabled">
47
+
48
+ // ─── Pointer shape ────────────────────────────────────────────────────────────
49
+
50
+ // TODO: add to pictureSelector.json — pointer is 24w × 12h triangle in Figma.
51
+ // These aren't container dimensions — they're the triangle's border widths
52
+ // (the CSS/RN triangle trick), which *are* the shape itself.
53
+ const POINTER_HALF_WIDTH = 12
54
+ const POINTER_HEIGHT = 12
55
+
56
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
57
+
58
+ const parseTokenValue = (value: string): number => parseFloat(value)
59
+
60
+ // ─── Styled primitives ────────────────────────────────────────────────────────
61
+
62
+ const StyledRoot = styled(View)<{ rootGap: number }>(({ rootGap }) => ({
63
+ flexDirection: "column",
64
+ gap: rootGap,
65
+ width: "100%"
66
+ }))
67
+
68
+ const StyledRow = styled(View)<{ rowGap: number }>(({ rowGap }) => ({
69
+ flexDirection: "row",
70
+ alignItems: "flex-start",
71
+ gap: rowGap,
72
+ width: "100%"
73
+ }))
74
+
75
+ const StyledItemStack = styled(View)<{ stackGap: number }>(({ stackGap }) => ({
76
+ flexDirection: "column",
77
+ alignItems: "center",
78
+ gap: stackGap,
79
+ flex: 1,
80
+ minWidth: 0
81
+ }))
82
+
83
+ const StyledPictureButton = styled(Pressable)<{
84
+ buttonBorderRadius: number
85
+ buttonBorderWidth: number
86
+ buttonBorderColor: string
87
+ buttonBgColor: string
88
+ buttonPadding: number
89
+ buttonOpacity: number
90
+ buttonWidth?: number
91
+ buttonAspectRatio: number
92
+ buttonMinWidth?: number
93
+ buttonMinHeight?: number
94
+ }>(
95
+ ({
96
+ buttonBorderRadius,
97
+ buttonBorderWidth,
98
+ buttonBorderColor,
99
+ buttonBgColor,
100
+ buttonPadding,
101
+ buttonOpacity,
102
+ buttonWidth,
103
+ buttonAspectRatio,
104
+ buttonMinWidth,
105
+ buttonMinHeight
106
+ }) => ({
107
+ alignItems: "center",
108
+ justifyContent: "center",
109
+ width: buttonWidth !== undefined ? buttonWidth : "100%",
110
+ aspectRatio: buttonAspectRatio,
111
+ ...(buttonMinWidth !== undefined && { minWidth: buttonMinWidth }),
112
+ ...(buttonMinHeight !== undefined && { minHeight: buttonMinHeight }),
113
+ padding: buttonPadding,
114
+ borderRadius: buttonBorderRadius,
115
+ borderWidth: buttonBorderWidth,
116
+ borderColor: buttonBorderColor,
117
+ backgroundColor: buttonBgColor,
118
+ opacity: buttonOpacity,
119
+ overflow: "hidden"
120
+ })
121
+ )
122
+
123
+ const StyledPointerTriangle = styled(View)<{
124
+ pointerColor: string
125
+ pointerVisible: boolean
126
+ }>(({ pointerColor, pointerVisible }) => ({
127
+ // 0×0 is part of the RN triangle-via-borders trick, not a layout
128
+ // dimension. Borders compose into a down-pointing triangle (apex at
129
+ // bottom) on RN; the `rotate 180deg` transform flips it to apex-up so
130
+ // the pointer visually connects the selected button to the insight
131
+ // card below. Using rotate instead of `borderBottom` because RN's
132
+ // triangle-via-borders rendering is inconsistent when the coloured
133
+ // border is on the bottom with transparent left/right on certain
134
+ // platforms.
135
+ width: 0,
136
+ height: 0,
137
+ borderLeftWidth: POINTER_HALF_WIDTH,
138
+ borderRightWidth: POINTER_HALF_WIDTH,
139
+ borderTopWidth: POINTER_HEIGHT,
140
+ borderLeftColor: "transparent",
141
+ borderRightColor: "transparent",
142
+ borderTopColor: pointerColor,
143
+ borderStyle: "solid",
144
+ transform: [{ rotate: "180deg" }],
145
+ opacity: pointerVisible ? 1 : 0
146
+ }))
147
+
148
+ // ─── Component ────────────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * A horizontal group of image/illustration buttons where one is selectable at
152
+ * a time. When the selected item has `insightTitle`/`insightDescription`, a
153
+ * `MessageCard.Insight` (standalone variant) renders below the row with a
154
+ * pointer beneath the selected button linking the two. The pointer moves with
155
+ * the selection.
156
+ *
157
+ * By default each card fills the width of its flex item (`width: 100%`)
158
+ * and is square (`aspectRatio: 1`). Override with `itemWidth` for a fixed
159
+ * width, `itemAspectRatio` for a non-square shape, and `itemMinWidth` /
160
+ * `itemMinHeight` for per-dim minimum floors.
161
+ *
162
+ * @param {string} [label] - Optional headline above the row.
163
+ * @param {PictureSelectorItem[]} items - 3 or 4 items to choose between.
164
+ * @param {string} [value] - Controlled selected value.
165
+ * @param {string} [defaultValue] - Uncontrolled initial selected value.
166
+ * @param {(value: string) => void} [onValueChange] - Fires when selection changes.
167
+ * @param {number} [itemWidth] - Fixed card width (px). Defaults to `100%`.
168
+ * @param {number} [itemAspectRatio=1] - Card aspect ratio (width / height).
169
+ * @param {number} [itemMinWidth] - Optional override for the card's min-width.
170
+ * Defaults to `pictureButton.size.minWidth` (80px).
171
+ * @param {number} [itemMinHeight] - Optional override for the card's min-height.
172
+ * Defaults to `pictureButton.size.minHeight` (120px).
173
+ */
174
+ export const PictureSelector = React.forwardRef<View, PictureSelectorProps>(
175
+ (
176
+ {
177
+ label,
178
+ items,
179
+ value,
180
+ defaultValue,
181
+ onValueChange,
182
+ itemWidth,
183
+ itemAspectRatio = 1,
184
+ itemMinWidth,
185
+ itemMinHeight,
186
+ ...rest
187
+ },
188
+ ref
189
+ ) => {
190
+ const theme = useTheme()
191
+ const { pictureSelector } = theme.tokens.components
192
+ const { pictureButton } = pictureSelector
193
+
194
+ const isControlled = value !== undefined
195
+ const [internalValue, setInternalValue] = React.useState<
196
+ string | undefined
197
+ >(defaultValue)
198
+ const selectedValue = isControlled ? value : internalValue
199
+ const selectedItem = items.find((item) => item.value === selectedValue)
200
+ const hasInsight = Boolean(
201
+ selectedItem?.insightTitle || selectedItem?.insightDescription
202
+ )
203
+
204
+ const handleSelect = (nextValue: string) => {
205
+ if (!isControlled) setInternalValue(nextValue)
206
+ onValueChange?.(nextValue)
207
+ }
208
+
209
+ const rootGap = parseTokenValue(pictureSelector.spacing.container.gap)
210
+ const rowGap = parseTokenValue(pictureSelector.spacing.buttons.gap)
211
+ const stackGap = parseTokenValue(pictureButton.spacing.container.gap)
212
+ const buttonPadding = parseTokenValue(pictureButton.spacing.padding)
213
+ const buttonBorderRadius = parseTokenValue(
214
+ pictureButton.borderRadius.default
215
+ )
216
+ const borderWidthDefault = parseTokenValue(
217
+ pictureButton.borderWidth.default
218
+ )
219
+ const borderWidthActive = parseTokenValue(pictureButton.borderWidth.active)
220
+ const resolvedMinWidth =
221
+ itemMinWidth ?? parseTokenValue(pictureButton.size.minWidth)
222
+ // Default min-height to min-width so the cards stay square at the floor,
223
+ // regardless of how many items are in the row.
224
+ const resolvedMinHeight = itemMinHeight ?? resolvedMinWidth
225
+
226
+ return (
227
+ <StyledRoot
228
+ ref={ref}
229
+ rootGap={rootGap}
230
+ accessible
231
+ accessibilityRole="radiogroup"
232
+ accessibilityLabel={label}
233
+ {...rest}
234
+ >
235
+ {label && (
236
+ <Typography
237
+ token={pictureSelector.typography.label}
238
+ color={pictureSelector.colour.text.default}
239
+ >
240
+ {label}
241
+ </Typography>
242
+ )}
243
+ <StyledRow rowGap={rowGap}>
244
+ {items.map((item) => {
245
+ const isSelected = item.value === selectedValue
246
+ const Visual = item.illustration
247
+ const IconComp = item.icon
248
+ const buttonProps: Omit<
249
+ React.ComponentProps<typeof StyledPictureButton>,
250
+ keyof PressableOwnForItem
251
+ > = {
252
+ buttonBorderRadius,
253
+ buttonBorderWidth: isSelected
254
+ ? borderWidthActive
255
+ : borderWidthDefault,
256
+ buttonBorderColor: isSelected
257
+ ? pictureButton.colour.border.active
258
+ : pictureButton.colour.border.default,
259
+ buttonBgColor: isSelected
260
+ ? pictureButton.colour.background.active
261
+ : pictureButton.colour.background.default,
262
+ buttonPadding,
263
+ buttonOpacity: item.disabled ? 0.5 : 1,
264
+ buttonWidth: itemWidth,
265
+ buttonAspectRatio: itemAspectRatio,
266
+ buttonMinWidth: resolvedMinWidth,
267
+ buttonMinHeight: resolvedMinHeight
268
+ }
269
+
270
+ return (
271
+ <StyledItemStack key={item.value} stackGap={stackGap}>
272
+ <StyledPictureButton
273
+ accessible
274
+ accessibilityRole="radio"
275
+ accessibilityState={{
276
+ selected: isSelected,
277
+ disabled: item.disabled
278
+ }}
279
+ accessibilityLabel={item.ariaLabel}
280
+ disabled={item.disabled}
281
+ onPress={() => {
282
+ if (!item.disabled) handleSelect(item.value)
283
+ }}
284
+ {...buttonProps}
285
+ >
286
+ {Visual ? (
287
+ <Illustration illustration={Visual} size="sm" />
288
+ ) : IconComp ? (
289
+ <Icon icon={IconComp} size="2xl" />
290
+ ) : null}
291
+ </StyledPictureButton>
292
+ <StyledPointerTriangle
293
+ pointerColor={pictureButton.colour.pointer.default}
294
+ pointerVisible={isSelected && hasInsight}
295
+ />
296
+ </StyledItemStack>
297
+ )
298
+ })}
299
+ </StyledRow>
300
+ {hasInsight && selectedItem && (
301
+ <MessageCard.Insight
302
+ variant="standalone"
303
+ title={selectedItem.insightTitle}
304
+ >
305
+ {selectedItem.insightDescription}
306
+ </MessageCard.Insight>
307
+ )}
308
+ </StyledRoot>
309
+ )
310
+ }
311
+ )
312
+
313
+ 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"