@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,9 +1,20 @@
1
- import React, { useCallback, useRef } from "react"
2
- import { View, ViewProps, PanResponder, LayoutChangeEvent } from "react-native"
1
+ import React, { useEffect, useState } from "react"
2
+ import { View, ViewProps, LayoutChangeEvent, Platform } from "react-native"
3
3
  import styled from "@emotion/native"
4
4
  import { useTheme } from "@emotion/react"
5
5
  import * as SliderPrimitive from "@rn-primitives/slider"
6
+ import { Gesture, GestureDetector } from "react-native-gesture-handler"
7
+ import Animated, {
8
+ useAnimatedStyle,
9
+ useSharedValue
10
+ } from "react-native-reanimated"
11
+ import { scheduleOnRN } from "react-native-worklets"
6
12
  import { Typography } from "../../atoms/Typography"
13
+ import { Icon } from "../../atoms/Icon"
14
+ import {
15
+ KeyboardArrowLeft,
16
+ KeyboardArrowRight
17
+ } from "@butternutbox/pawprint-icons/core"
7
18
 
8
19
  type SliderOwnProps = {
9
20
  value?: number
@@ -25,24 +36,6 @@ export type SliderProps = SliderOwnProps & Omit<ViewProps, keyof SliderOwnProps>
25
36
 
26
37
  const parseTokenValue = (value: string): number => parseFloat(value)
27
38
 
28
- const clamp = (val: number, min: number, max: number): number =>
29
- Math.min(Math.max(val, min), max)
30
-
31
- const snap = (val: number, step: number, min: number): number => {
32
- const steps = Math.round((val - min) / step)
33
- return min + steps * step
34
- }
35
-
36
- const StyledSliderRoot = styled(SliderPrimitive.Root)<{
37
- sliderGap: number
38
- sliderMinWidth: number
39
- sliderOpacity: number
40
- }>(({ sliderGap, sliderMinWidth, sliderOpacity }) => ({
41
- gap: sliderGap,
42
- minWidth: sliderMinWidth,
43
- opacity: sliderOpacity
44
- }))
45
-
46
39
  const StyledLabelRow = styled(View)<{
47
40
  labelRowGap: number
48
41
  }>(({ labelRowGap }) => ({
@@ -60,60 +53,6 @@ const StyledLabelSlot = styled(View)<{
60
53
  flexShrink: 0
61
54
  }))
62
55
 
63
- const StyledTrackArea = styled(View)<{
64
- trackAreaHeight: number
65
- }>(({ trackAreaHeight }) => ({
66
- flex: 1,
67
- height: trackAreaHeight,
68
- justifyContent: "center"
69
- }))
70
-
71
- const StyledTrack = styled(SliderPrimitive.Track)<{
72
- trackHeight: number
73
- trackBorderRadius: number
74
- trackBgColor: string
75
- trackBorderWidth: number
76
- trackBorderColor: string
77
- }>(
78
- ({
79
- trackHeight,
80
- trackBorderRadius,
81
- trackBgColor,
82
- trackBorderWidth,
83
- trackBorderColor
84
- }) => ({
85
- height: trackHeight,
86
- borderRadius: trackBorderRadius,
87
- backgroundColor: trackBgColor,
88
- borderWidth: trackBorderWidth,
89
- borderColor: trackBorderColor,
90
- overflow: "hidden"
91
- })
92
- )
93
-
94
- const StyledRange = styled(SliderPrimitive.Range)<{
95
- rangeBgColor: string
96
- rangeBorderRadius: number
97
- }>(({ rangeBgColor, rangeBorderRadius }) => ({
98
- height: "100%",
99
- backgroundColor: rangeBgColor,
100
- borderRadius: rangeBorderRadius
101
- }))
102
-
103
- const StyledThumb = styled(SliderPrimitive.Thumb)<{
104
- thumbSize: number
105
- thumbBorderRadius: number
106
- thumbBgColor: string
107
- }>(({ thumbSize, thumbBorderRadius, thumbBgColor }) => ({
108
- position: "absolute",
109
- width: thumbSize,
110
- height: thumbSize,
111
- borderRadius: thumbBorderRadius,
112
- backgroundColor: thumbBgColor,
113
- alignItems: "center",
114
- justifyContent: "center"
115
- }))
116
-
117
56
  /**
118
57
  * Slider component for selecting a numeric value within a min/max range.
119
58
  *
@@ -162,45 +101,118 @@ export const Slider = React.forwardRef<View, SliderProps>(
162
101
  const [internalValue, setInternalValue] = React.useState(defaultValue)
163
102
  const currentValue = isControlled ? controlledValue : internalValue
164
103
 
165
- const trackWidth = useRef(0)
166
104
  const thumbSize = parseTokenValue(buttons.size.md.height)
167
105
  const trackHeight = parseTokenValue(slider.sizing.track.height)
168
106
 
169
- const fraction = max > min ? (currentValue - min) / (max - min) : 0
107
+ const isWeb = Platform.OS === "web"
108
+
109
+ // Web: layout-driven position. Native: UI-thread shared values.
110
+ const [trackLayoutWidth, setTrackLayoutWidth] = useState(0)
111
+ const webFraction = max > min ? (currentValue - min) / (max - min) : 0
112
+
113
+ const trackWidth = useSharedValue(0)
114
+ const thumbLeft = useSharedValue(0)
115
+ const indicatorWidth = useSharedValue(thumbSize / 2)
116
+ const lastReported = useSharedValue(currentValue)
117
+
118
+ const handleJSUpdate = (v: number) => {
119
+ if (!isControlled) setInternalValue(v)
120
+ onValueChange?.(v)
121
+ }
122
+
123
+ const handleCommit = (v: number) => {
124
+ if (!isControlled) setInternalValue(v)
125
+ onValueCommitted?.(v)
126
+ }
127
+
128
+ // Sync external value → shared values (initial mount + controlled updates).
129
+ useEffect(() => {
130
+ const w = trackWidth.value
131
+ const frac = max > min ? (currentValue - min) / (max - min) : 0
132
+ const nextLeft = w > 0 ? frac * (w - thumbSize) : 0
133
+ thumbLeft.value = nextLeft
134
+ indicatorWidth.value = nextLeft + thumbSize / 2
135
+ lastReported.value = currentValue
136
+ }, [
137
+ currentValue,
138
+ min,
139
+ max,
140
+ thumbSize,
141
+ trackWidth,
142
+ thumbLeft,
143
+ indicatorWidth,
144
+ lastReported
145
+ ])
170
146
 
171
- const handleLayout = (e: LayoutChangeEvent) => {
172
- trackWidth.current = e.nativeEvent.layout.width
147
+ const handleNativeLayout = (e: LayoutChangeEvent) => {
148
+ const w = e.nativeEvent.layout.width
149
+ trackWidth.value = w
150
+ const frac = max > min ? (currentValue - min) / (max - min) : 0
151
+ const nextLeft = frac * (w - thumbSize)
152
+ thumbLeft.value = nextLeft
153
+ indicatorWidth.value = nextLeft + thumbSize / 2
173
154
  }
174
155
 
175
- const updateValue = useCallback(
176
- (locationX: number) => {
177
- if (disabled || trackWidth.current === 0) return
178
- const ratio = clamp(locationX / trackWidth.current, 0, 1)
156
+ const pan = Gesture.Pan()
157
+ .enabled(!disabled)
158
+ .minDistance(0)
159
+ .onBegin((e) => {
160
+ "worklet"
161
+ const w = trackWidth.value
162
+ if (w <= 0) return
163
+ const ratio = Math.min(Math.max(e.x / w, 0), 1)
179
164
  const raw = min + ratio * (max - min)
180
- const snapped = snap(clamp(raw, min, max), step, min)
181
- if (!isControlled) {
182
- setInternalValue(snapped)
165
+ const steps = Math.round((raw - min) / step)
166
+ const snapped = Math.min(Math.max(min + steps * step, min), max)
167
+ const frac = max > min ? (snapped - min) / (max - min) : 0
168
+ const nextLeft = frac * (w - thumbSize)
169
+ thumbLeft.value = nextLeft
170
+ indicatorWidth.value = nextLeft + thumbSize / 2
171
+ if (snapped !== lastReported.value) {
172
+ lastReported.value = snapped
173
+ scheduleOnRN(handleJSUpdate, snapped)
183
174
  }
184
- onValueChange?.(snapped)
185
- },
186
- [disabled, min, max, step, isControlled, onValueChange]
187
- )
188
-
189
- const panResponder = useRef(
190
- PanResponder.create({
191
- onStartShouldSetPanResponder: () => !disabled,
192
- onMoveShouldSetPanResponder: () => !disabled,
193
- onPanResponderGrant: (e) => {
194
- updateValue(e.nativeEvent.locationX)
195
- },
196
- onPanResponderMove: (e) => {
197
- updateValue(e.nativeEvent.locationX)
198
- },
199
- onPanResponderRelease: () => {
200
- onValueCommitted?.(currentValue)
175
+ })
176
+ .onChange((e) => {
177
+ "worklet"
178
+ const w = trackWidth.value
179
+ if (w <= 0) return
180
+ const ratio = Math.min(Math.max(e.x / w, 0), 1)
181
+ const raw = min + ratio * (max - min)
182
+ const steps = Math.round((raw - min) / step)
183
+ const snapped = Math.min(Math.max(min + steps * step, min), max)
184
+ const frac = max > min ? (snapped - min) / (max - min) : 0
185
+ const nextLeft = frac * (w - thumbSize)
186
+ thumbLeft.value = nextLeft
187
+ indicatorWidth.value = nextLeft + thumbSize / 2
188
+ if (snapped !== lastReported.value) {
189
+ lastReported.value = snapped
190
+ scheduleOnRN(handleJSUpdate, snapped)
201
191
  }
202
192
  })
203
- ).current
193
+ .onEnd(() => {
194
+ "worklet"
195
+ scheduleOnRN(handleCommit, lastReported.value)
196
+ })
197
+
198
+ const thumbAnimStyle = useAnimatedStyle(() => ({
199
+ transform: [{ translateX: thumbLeft.value }]
200
+ }))
201
+ const indicatorAnimStyle = useAnimatedStyle(() => ({
202
+ width: indicatorWidth.value
203
+ }))
204
+
205
+ const handleWebValueChange = (values: number[]) => {
206
+ const newValue = values[0]
207
+ if (!isControlled) {
208
+ setInternalValue(newValue)
209
+ }
210
+ onValueChange?.(newValue)
211
+ }
212
+
213
+ const handleWebLayout = (e: LayoutChangeEvent) => {
214
+ setTrackLayoutWidth(e.nativeEvent.layout.width)
215
+ }
204
216
 
205
217
  const hasLeading = leadingIcon !== undefined || leadingLabel !== undefined
206
218
  const hasTrailing =
@@ -209,93 +221,194 @@ export const Slider = React.forwardRef<View, SliderProps>(
209
221
  const labelGap = parseTokenValue(slider.sliderField.spacing.label.gap)
210
222
  const roundBorderRadius = parseTokenValue(dimensions.borderRadius.round)
211
223
 
224
+ const thumbIconColor =
225
+ buttons.iconButton.filledButton.colour.icon.primary.default
226
+
227
+ const thumbContent = (
228
+ <>
229
+ <Icon
230
+ icon={KeyboardArrowLeft}
231
+ size="sm"
232
+ customColour={thumbIconColor}
233
+ style={{ marginRight: -3 }}
234
+ />
235
+ <Icon
236
+ icon={KeyboardArrowRight}
237
+ size="sm"
238
+ customColour={thumbIconColor}
239
+ style={{ marginLeft: -3 }}
240
+ />
241
+ </>
242
+ )
243
+
244
+ const thumbStyle = {
245
+ width: thumbSize,
246
+ height: thumbSize,
247
+ borderRadius: parseTokenValue(buttons.borderRadius.default),
248
+ backgroundColor:
249
+ buttons.iconButton.filledButton.colour.background.primary.default,
250
+ flexDirection: "row" as const,
251
+ alignItems: "center" as const,
252
+ justifyContent: "center" as const
253
+ }
254
+
255
+ const outerStyle = {
256
+ gap: parseTokenValue(slider.sliderField.spacing.gap),
257
+ minWidth: parseTokenValue(slider.sizing.minWidth),
258
+ opacity: disabled ? parseFloat(buttons.opacity.disabled) : 1
259
+ }
260
+
261
+ const leadingSlot = hasLeading && (
262
+ <StyledLabelSlot labelSlotGap={labelGap}>
263
+ {leadingIcon}
264
+ {leadingLabel && (
265
+ <Typography
266
+ token={slider.sliderField.typography.label}
267
+ color={slider.sliderField.colour.text.label}
268
+ >
269
+ {leadingLabel}
270
+ </Typography>
271
+ )}
272
+ </StyledLabelSlot>
273
+ )
274
+
275
+ const trailingSlot = hasTrailing && (
276
+ <StyledLabelSlot labelSlotGap={labelGap}>
277
+ {trailingLabel && (
278
+ <Typography
279
+ token={slider.sliderField.typography.label}
280
+ color={slider.sliderField.colour.text.label}
281
+ >
282
+ {trailingLabel}
283
+ </Typography>
284
+ )}
285
+ {trailingIcon}
286
+ </StyledLabelSlot>
287
+ )
288
+
289
+ const descriptionEl = description && (
290
+ <Typography
291
+ token={slider.sliderField.typography.description}
292
+ color={slider.sliderField.colour.text.description}
293
+ align="center"
294
+ >
295
+ {description}
296
+ </Typography>
297
+ )
298
+
299
+ const trackBackgroundStyle = {
300
+ height: trackHeight,
301
+ borderRadius: roundBorderRadius,
302
+ backgroundColor: slider.colour.track.background.default,
303
+ borderWidth: parseTokenValue(dimensions.borderWidth.sm),
304
+ borderColor: slider.colour.track.border.default
305
+ }
306
+ const indicatorBaseStyle = {
307
+ position: "absolute" as const,
308
+ left: 0,
309
+ top: (thumbSize - trackHeight) / 2,
310
+ height: trackHeight,
311
+ borderRadius: roundBorderRadius,
312
+ backgroundColor: slider.colour.indicator.background.default
313
+ }
314
+
315
+ if (isWeb) {
316
+ const webThumbLeft = webFraction * (trackLayoutWidth - thumbSize)
317
+ const webIndicatorWidth = webThumbLeft + thumbSize / 2
318
+ return (
319
+ <View ref={ref} style={outerStyle} {...rest}>
320
+ <StyledLabelRow labelRowGap={labelGap}>
321
+ {leadingSlot}
322
+
323
+ <SliderPrimitive.Root
324
+ value={currentValue}
325
+ min={min}
326
+ max={max}
327
+ step={step}
328
+ disabled={disabled}
329
+ onValueChange={handleWebValueChange}
330
+ onLayout={handleWebLayout}
331
+ style={{
332
+ flex: 1,
333
+ position: "relative" as const,
334
+ height: thumbSize,
335
+ justifyContent: "center" as const
336
+ }}
337
+ >
338
+ <View style={trackBackgroundStyle} />
339
+ <View
340
+ style={{ ...indicatorBaseStyle, width: webIndicatorWidth }}
341
+ />
342
+ <View
343
+ style={{
344
+ ...thumbStyle,
345
+ position: "absolute",
346
+ left: webThumbLeft
347
+ }}
348
+ >
349
+ {thumbContent}
350
+ </View>
351
+ {/* Invisible Radix interaction layer for pointer/keyboard */}
352
+ <SliderPrimitive.Track
353
+ style={{
354
+ position: "absolute" as const,
355
+ top: 0,
356
+ left: 0,
357
+ right: 0,
358
+ bottom: 0,
359
+ opacity: 0
360
+ }}
361
+ />
362
+ </SliderPrimitive.Root>
363
+
364
+ {trailingSlot}
365
+ </StyledLabelRow>
366
+
367
+ {descriptionEl}
368
+ </View>
369
+ )
370
+ }
371
+
372
+ // Native: UI-thread gesture via Reanimated + Gesture Handler.
212
373
  return (
213
- <StyledSliderRoot
374
+ <View
214
375
  ref={ref}
215
- value={currentValue}
216
- min={min}
217
- max={max}
218
- disabled={disabled}
219
- sliderGap={parseTokenValue(slider.sliderField.spacing.gap)}
220
- sliderMinWidth={parseTokenValue(slider.sizing.minWidth)}
221
- sliderOpacity={disabled ? parseFloat(buttons.opacity.disabled) : 1}
376
+ accessibilityRole="adjustable"
377
+ accessibilityValue={{ min, max, now: currentValue }}
378
+ accessibilityState={{ disabled }}
379
+ style={outerStyle}
222
380
  {...rest}
223
381
  >
224
382
  <StyledLabelRow labelRowGap={labelGap}>
225
- {hasLeading && (
226
- <StyledLabelSlot labelSlotGap={labelGap}>
227
- {leadingIcon}
228
- {leadingLabel && (
229
- <Typography
230
- token={slider.sliderField.typography.label}
231
- color={slider.sliderField.colour.text.label}
232
- >
233
- {leadingLabel}
234
- </Typography>
235
- )}
236
- </StyledLabelSlot>
237
- )}
238
-
239
- <StyledTrackArea
240
- trackAreaHeight={thumbSize}
241
- onLayout={handleLayout}
242
- {...panResponder.panHandlers}
243
- >
244
- <StyledTrack
245
- trackHeight={trackHeight}
246
- trackBorderRadius={roundBorderRadius}
247
- trackBgColor={slider.colour.track.background.default}
248
- trackBorderWidth={parseTokenValue(dimensions.borderWidth.sm)}
249
- trackBorderColor={slider.colour.track.border.default}
250
- >
251
- <StyledRange
252
- rangeBgColor={slider.colour.indicator.background.default}
253
- rangeBorderRadius={roundBorderRadius}
254
- style={{ width: `${fraction * 100}%` }}
255
- />
256
- </StyledTrack>
257
- <StyledThumb
258
- thumbSize={thumbSize}
383
+ {leadingSlot}
384
+
385
+ <GestureDetector gesture={pan}>
386
+ <View
259
387
  style={{
260
- left: `${fraction * 100}%`,
261
- marginLeft: -thumbSize / 2
388
+ flex: 1,
389
+ height: thumbSize,
390
+ justifyContent: "center"
262
391
  }}
263
- thumbBorderRadius={parseTokenValue(buttons.borderRadius.default)}
264
- thumbBgColor={
265
- buttons.iconButton.filledButton.colour.background.primary
266
- .default
267
- }
392
+ onLayout={handleNativeLayout}
268
393
  >
269
- <Typography size="xs" color="alt">
270
- ⟨⟩
271
- </Typography>
272
- </StyledThumb>
273
- </StyledTrackArea>
274
-
275
- {hasTrailing && (
276
- <StyledLabelSlot labelSlotGap={labelGap}>
277
- {trailingLabel && (
278
- <Typography
279
- token={slider.sliderField.typography.label}
280
- color={slider.sliderField.colour.text.label}
281
- >
282
- {trailingLabel}
283
- </Typography>
284
- )}
285
- {trailingIcon}
286
- </StyledLabelSlot>
287
- )}
394
+ <View style={trackBackgroundStyle} />
395
+ <Animated.View style={[indicatorBaseStyle, indicatorAnimStyle]} />
396
+ <Animated.View
397
+ style={[
398
+ { ...thumbStyle, position: "absolute", left: 0 },
399
+ thumbAnimStyle
400
+ ]}
401
+ >
402
+ {thumbContent}
403
+ </Animated.View>
404
+ </View>
405
+ </GestureDetector>
406
+
407
+ {trailingSlot}
288
408
  </StyledLabelRow>
289
409
 
290
- {description && (
291
- <Typography
292
- token={slider.sliderField.typography.description}
293
- color={slider.sliderField.colour.text.description}
294
- >
295
- {description}
296
- </Typography>
297
- )}
298
- </StyledSliderRoot>
410
+ {descriptionEl}
411
+ </View>
299
412
  )
300
413
  }
301
414
  )
@@ -0,0 +1,168 @@
1
+ import React from "react"
2
+ import { View, StyleSheet } from "react-native"
3
+ import { Typography } from "../../atoms/Typography"
4
+ import { Button } from "../../atoms/Button"
5
+ import { Tooltip, type TooltipProps } from "./Tooltip"
6
+
7
+ export default {
8
+ title: "Molecules/Tooltip",
9
+ component: Tooltip,
10
+ argTypes: {
11
+ text: {
12
+ control: { type: "text" },
13
+ description: "Tooltip copy"
14
+ },
15
+ alignment: {
16
+ control: { type: "select" },
17
+ options: ["left", "middle", "right"],
18
+ description: "Horizontal position of the nib"
19
+ },
20
+ position: {
21
+ control: { type: "select" },
22
+ options: ["top", "bottom"],
23
+ description: "Whether the nib appears above or below the body"
24
+ }
25
+ }
26
+ }
27
+
28
+ export const Playground = (args: TooltipProps) => (
29
+ <View style={styles.container}>
30
+ <Tooltip {...args}>
31
+ <Button variant="filled" colour="primary">
32
+ Tap me
33
+ </Button>
34
+ </Tooltip>
35
+ </View>
36
+ )
37
+ Playground.args = {
38
+ text: "This is a helpful tooltip with some descriptive content.",
39
+ alignment: "middle",
40
+ position: "bottom"
41
+ }
42
+
43
+ export const AllAlignments = () => (
44
+ <View style={styles.column}>
45
+ <Typography variant="heading" size="sm">
46
+ Nib alignments — position: top
47
+ </Typography>
48
+ {(["left", "middle", "right"] as const).map((alignment) => (
49
+ <View key={alignment} style={styles.section}>
50
+ <Typography size="sm">alignment={alignment}</Typography>
51
+ <Tooltip
52
+ text="Helpful tooltip content goes here."
53
+ alignment={alignment}
54
+ position="top"
55
+ />
56
+ </View>
57
+ ))}
58
+ </View>
59
+ )
60
+
61
+ export const Positions = () => (
62
+ <View style={styles.column}>
63
+ <View style={styles.section}>
64
+ <Typography variant="heading" size="sm">
65
+ position: top (nib above body)
66
+ </Typography>
67
+ <Tooltip
68
+ text="Tooltip appears below the trigger."
69
+ alignment="middle"
70
+ position="top"
71
+ />
72
+ </View>
73
+ <View style={styles.section}>
74
+ <Typography variant="heading" size="sm">
75
+ position: bottom (nib below body)
76
+ </Typography>
77
+ <Tooltip
78
+ text="Tooltip appears above the trigger."
79
+ alignment="middle"
80
+ position="bottom"
81
+ />
82
+ </View>
83
+ </View>
84
+ )
85
+
86
+ export const AllCombinations = () => (
87
+ <View style={styles.column}>
88
+ {(["top", "bottom"] as const).map((position) => (
89
+ <View key={position} style={styles.section}>
90
+ <Typography variant="heading" size="sm">
91
+ position: {position}
92
+ </Typography>
93
+ <View style={styles.row}>
94
+ {(["left", "middle", "right"] as const).map((alignment) => (
95
+ <View key={alignment} style={styles.combo}>
96
+ <Typography size="xs">{alignment}</Typography>
97
+ <Tooltip
98
+ text="Tooltip text."
99
+ alignment={alignment}
100
+ position={position}
101
+ />
102
+ </View>
103
+ ))}
104
+ </View>
105
+ </View>
106
+ ))}
107
+ </View>
108
+ )
109
+
110
+ export const LongText = () => (
111
+ <View style={styles.column}>
112
+ <Typography variant="heading" size="sm">
113
+ Long content
114
+ </Typography>
115
+ <Tooltip
116
+ text="This tooltip contains a longer description that wraps across multiple lines to show how the component handles longer content gracefully."
117
+ alignment="middle"
118
+ position="top"
119
+ />
120
+ </View>
121
+ )
122
+
123
+ export const InContext = () => (
124
+ <View style={styles.column}>
125
+ <Tooltip
126
+ text="This action cannot be undone."
127
+ alignment="middle"
128
+ position="bottom"
129
+ >
130
+ <Button variant="filled" colour="primary">
131
+ Delete account
132
+ </Button>
133
+ </Tooltip>
134
+ <Tooltip
135
+ text="This action cannot be undone."
136
+ alignment="middle"
137
+ position="top"
138
+ >
139
+ <Button variant="filled" colour="primary">
140
+ Delete account
141
+ </Button>
142
+ </Tooltip>
143
+ </View>
144
+ )
145
+
146
+ const styles = StyleSheet.create({
147
+ container: {
148
+ padding: 24,
149
+ alignItems: "flex-start"
150
+ },
151
+ column: {
152
+ padding: 24,
153
+ gap: 24
154
+ },
155
+ section: {
156
+ gap: 8,
157
+ alignItems: "flex-start"
158
+ },
159
+ row: {
160
+ flexDirection: "row",
161
+ gap: 16,
162
+ flexWrap: "wrap"
163
+ },
164
+ combo: {
165
+ gap: 4,
166
+ alignItems: "flex-start"
167
+ }
168
+ })