@titan-design/react-ui 0.1.0 → 0.2.5

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 (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,307 @@
1
+ import React, { useState, useCallback, createContext, useContext } from 'react'
2
+ import { View, Text, Pressable, type ViewProps, StyleSheet, Platform } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+ import { neumorphicShadows, getHoverColors } from '../../../theme'
5
+
6
+ export type ToolbarButtonVariant = 'default' | 'neumorphic'
7
+ export type ToolbarButtonSize = 'sm' | 'md' | 'lg'
8
+
9
+ interface ToolbarButtonContextType {
10
+ isOpen: boolean
11
+ setIsOpen: (open: boolean) => void
12
+ }
13
+
14
+ const ToolbarButtonContext = createContext<ToolbarButtonContextType>({
15
+ isOpen: false,
16
+ setIsOpen: () => {},
17
+ })
18
+
19
+ export interface ToolbarButtonProps extends ViewProps {
20
+ /** Button label */
21
+ label: string
22
+ /** Icon component */
23
+ icon?: React.ReactNode
24
+ /**
25
+ * Whether the button is in active/selected state.
26
+ * - `true`: pressed/active appearance (darker, white text, orange icon)
27
+ * - `undefined`: pressed appearance (darker, white text/icon)
28
+ * - `false`: raised/inactive appearance (lighter, gray text/icon)
29
+ */
30
+ isActive?: boolean
31
+ /** Whether the button is disabled */
32
+ isDisabled?: boolean
33
+ /** Size of the button */
34
+ size?: ToolbarButtonSize
35
+ /** Visual variant */
36
+ variant?: ToolbarButtonVariant
37
+ /** Tooltip text (shown when label is hidden) */
38
+ tooltip?: string
39
+ /** Whether to show label (false = icon only) */
40
+ showLabel?: boolean
41
+ /** Press handler */
42
+ onPress?: () => void
43
+ /** Menu content (renders in popover) */
44
+ menuContent?: React.ReactNode
45
+ /** Additional className */
46
+ className?: string
47
+ }
48
+
49
+ // Base button color
50
+ const BUTTON_BG = '#3C3C3C'
51
+ const MENU_BG = '#2C2C2C'
52
+
53
+ // Calculate hover colors using color math
54
+ const hoverColors = getHoverColors(BUTTON_BG, 'medium')
55
+
56
+ // Size style maps
57
+ const sizeStyles: Record<ToolbarButtonSize, string> = {
58
+ sm: 'px-2 py-1 min-h-[26px]',
59
+ md: 'px-2.5 py-1 min-h-[30px]',
60
+ lg: 'px-3 py-1.5 min-h-[36px]',
61
+ }
62
+
63
+ const textSizeStyles: Record<ToolbarButtonSize, string> = {
64
+ sm: 'text-xs',
65
+ md: 'text-sm',
66
+ lg: 'text-base',
67
+ }
68
+
69
+ /**
70
+ * ToolbarButton component for toolbar actions with toggle state support.
71
+ *
72
+ * Features:
73
+ * - Neumorphic styling with inset shadows for pressed/raised 3D effect
74
+ * - Active (pressed) state is the default visual appearance
75
+ * - Set isActive={false} explicitly for the raised/inactive appearance
76
+ * - Orange accent color on icon when isActive={true}
77
+ * - Hover states that lighten background and remove shadows
78
+ *
79
+ * @example
80
+ * // Toggle button - orange icon when active
81
+ * <ToolbarButton
82
+ * label="Settings"
83
+ * icon={<SettingsIcon />}
84
+ * isActive={isSettingsOpen}
85
+ * onPress={() => setIsSettingsOpen(!isSettingsOpen)}
86
+ * />
87
+ *
88
+ * @example
89
+ * // Explicitly inactive (raised appearance)
90
+ * <ToolbarButton
91
+ * label="Filter"
92
+ * icon={<FilterIcon />}
93
+ * isActive={false}
94
+ * />
95
+ */
96
+ export function ToolbarButton({
97
+ label,
98
+ icon,
99
+ isActive,
100
+ isDisabled = false,
101
+ size = 'md',
102
+ variant = 'neumorphic',
103
+ tooltip,
104
+ showLabel = true,
105
+ onPress,
106
+ menuContent,
107
+ className,
108
+ children,
109
+ ...props
110
+ }: ToolbarButtonProps) {
111
+ const [isOpen, setIsOpen] = useState(false)
112
+ const [isHovered, setIsHovered] = useState(false)
113
+
114
+ const handlePress = useCallback(() => {
115
+ if (isDisabled) return
116
+ if (menuContent) {
117
+ setIsOpen((prev) => !prev)
118
+ }
119
+ onPress?.()
120
+ }, [isDisabled, menuContent, onPress])
121
+
122
+ const handleClose = useCallback(() => {
123
+ setIsOpen(false)
124
+ }, [])
125
+
126
+ // Match original logic: active !== false means "active" (sunken) appearance
127
+ const selected = isActive === undefined ? isOpen : isActive
128
+ const showActive = isActive !== false
129
+
130
+ // Determine icon color based on state
131
+ const getIconColor = () => {
132
+ if (isActive === true) return '#FF7900' // brand-primary
133
+ return showActive ? '#FFFFFF' : '#D1D1D1'
134
+ }
135
+
136
+ // Get shadow and background styles for neumorphic variant
137
+ const getNeumorphicStyle = () => {
138
+ if (isDisabled) {
139
+ return styles.disabledBg
140
+ }
141
+
142
+ const shadows = neumorphicShadows.charcoal
143
+
144
+ if (showActive) {
145
+ // Active = pressed/sunken appearance
146
+ return {
147
+ backgroundColor: isHovered ? hoverColors.pressed : BUTTON_BG,
148
+ ...(isHovered ? shadows.pressedHover.subtle : shadows.pressed.subtle),
149
+ }
150
+ } else {
151
+ // Inactive = raised/elevated appearance
152
+ return {
153
+ backgroundColor: isHovered ? hoverColors.raised : BUTTON_BG,
154
+ ...(isHovered ? shadows.raisedHover.subtle : shadows.raised.subtle),
155
+ }
156
+ }
157
+ }
158
+
159
+ return (
160
+ <ToolbarButtonContext.Provider value={{ isOpen, setIsOpen }}>
161
+ <View className="relative" {...props}>
162
+ <Pressable
163
+ onPress={handlePress}
164
+ onHoverIn={() => setIsHovered(true)}
165
+ onHoverOut={() => setIsHovered(false)}
166
+ disabled={isDisabled}
167
+ accessibilityRole="button"
168
+ accessibilityState={{ selected, disabled: isDisabled }}
169
+ accessibilityLabel={label}
170
+ accessibilityHint={tooltip}
171
+ className={cn(
172
+ // Base styles
173
+ 'flex-row items-center justify-center rounded',
174
+ 'web:transition-all web:duration-150 web:cursor-pointer',
175
+ // Size
176
+ sizeStyles[size],
177
+ // Disabled
178
+ isDisabled && 'opacity-40 web:cursor-default web:pointer-events-none',
179
+ className
180
+ )}
181
+ style={[
182
+ variant === 'neumorphic' && getNeumorphicStyle(),
183
+ variant === 'default' && {
184
+ backgroundColor: showActive ? BUTTON_BG : '#6E6E6E',
185
+ },
186
+ ]}
187
+ >
188
+ {icon && (
189
+ <View className="w-5 h-5 items-center justify-center">
190
+ {React.isValidElement(icon)
191
+ ? React.cloneElement(icon as React.ReactElement<any>, {
192
+ size: 20,
193
+ width: 20,
194
+ height: 20,
195
+ color: getIconColor(),
196
+ style: {
197
+ color: getIconColor(),
198
+ width: 20,
199
+ height: 20,
200
+ },
201
+ })
202
+ : icon
203
+ }
204
+ </View>
205
+ )}
206
+ {showLabel && (
207
+ <Text
208
+ className={cn(
209
+ 'font-bold',
210
+ textSizeStyles[size],
211
+ icon && 'ml-2',
212
+ showActive ? 'text-white' : 'text-[#D1D1D1]'
213
+ )}
214
+ numberOfLines={1}
215
+ >
216
+ {label}
217
+ </Text>
218
+ )}
219
+ </Pressable>
220
+
221
+ {/* Popover Menu */}
222
+ {menuContent && isOpen && (
223
+ <>
224
+ {/* Backdrop */}
225
+ <Pressable
226
+ onPress={handleClose}
227
+ style={StyleSheet.absoluteFill}
228
+ className="z-40"
229
+ />
230
+ {/* Menu Content */}
231
+ <View
232
+ className={cn(
233
+ 'absolute z-50 top-full left-0 mt-1',
234
+ 'rounded-lg shadow-lg border border-border-default',
235
+ 'min-w-[150px] overflow-hidden',
236
+ `bg-[${MENU_BG}]`
237
+ )}
238
+ >
239
+ {menuContent}
240
+ </View>
241
+ </>
242
+ )}
243
+ </View>
244
+ </ToolbarButtonContext.Provider>
245
+ )
246
+ }
247
+
248
+ // Styles that can't be easily expressed in Tailwind
249
+ const styles = StyleSheet.create({
250
+ // Disabled - flat gray background, no shadows
251
+ disabledBg: {
252
+ backgroundColor: 'rgba(255, 255, 255, 0.12)',
253
+ ...Platform.select({
254
+ web: { boxShadow: 'none' } as any,
255
+ default: { shadowOpacity: 0, elevation: 0 },
256
+ }),
257
+ },
258
+ })
259
+
260
+ export interface ToolbarButtonGroupProps extends ViewProps {
261
+ /** Orientation of the button group */
262
+ orientation?: 'horizontal' | 'vertical'
263
+ /** Gap between buttons */
264
+ gap?: 'none' | 'sm' | 'md'
265
+ /** Additional className */
266
+ className?: string
267
+ children?: React.ReactNode
268
+ }
269
+
270
+ const gapStyles: Record<string, string> = {
271
+ none: 'gap-0',
272
+ sm: 'gap-1',
273
+ md: 'gap-2',
274
+ }
275
+
276
+ /**
277
+ * Container for grouping toolbar buttons together.
278
+ */
279
+ export function ToolbarButtonGroup({
280
+ orientation = 'horizontal',
281
+ gap = 'sm',
282
+ className,
283
+ children,
284
+ ...props
285
+ }: ToolbarButtonGroupProps) {
286
+ return (
287
+ <View
288
+ className={cn(
289
+ orientation === 'horizontal' ? 'flex-row' : 'flex-col',
290
+ gapStyles[gap],
291
+ 'items-center',
292
+ className
293
+ )}
294
+ accessibilityRole="toolbar"
295
+ {...props}
296
+ >
297
+ {children}
298
+ </View>
299
+ )
300
+ }
301
+
302
+ /**
303
+ * Hook to access toolbar button context (for custom menu content)
304
+ */
305
+ export function useToolbarButton() {
306
+ return useContext(ToolbarButtonContext)
307
+ }
@@ -0,0 +1,7 @@
1
+ export { ToolbarButton, ToolbarButtonGroup, useToolbarButton } from './ToolbarButton'
2
+ export type {
3
+ ToolbarButtonProps,
4
+ ToolbarButtonGroupProps,
5
+ ToolbarButtonVariant,
6
+ ToolbarButtonSize,
7
+ } from './ToolbarButton'
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { Tooltip } from './Tooltip'
4
+ import { Button, ButtonText } from '../button'
5
+
6
+ const meta: Meta<typeof Tooltip> = {
7
+ title: 'Components/Tooltip',
8
+ component: Tooltip,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ placement: {
12
+ control: 'select',
13
+ options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'right'],
14
+ },
15
+ hasArrow: { control: 'boolean' },
16
+ isDisabled: { control: 'boolean' },
17
+ },
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof Tooltip>
22
+
23
+ export const Default: Story = {
24
+ render: () => (
25
+ <View style={{ padding: 100 }}>
26
+ <Tooltip label="This is a tooltip">
27
+ <Button>
28
+ <ButtonText>Hover me</ButtonText>
29
+ </Button>
30
+ </Tooltip>
31
+ </View>
32
+ ),
33
+ }
34
+
35
+ export const AllPlacements: Story = {
36
+ render: () => (
37
+ <View style={{ padding: 100, gap: 24 }}>
38
+ <View style={{ flexDirection: 'row', justifyContent: 'center', gap: 16 }}>
39
+ <Tooltip label="Top Start" placement="top-start">
40
+ <Button variant="outline" size="sm">
41
+ <ButtonText>Top Start</ButtonText>
42
+ </Button>
43
+ </Tooltip>
44
+ <Tooltip label="Top" placement="top">
45
+ <Button variant="outline" size="sm">
46
+ <ButtonText>Top</ButtonText>
47
+ </Button>
48
+ </Tooltip>
49
+ <Tooltip label="Top End" placement="top-end">
50
+ <Button variant="outline" size="sm">
51
+ <ButtonText>Top End</ButtonText>
52
+ </Button>
53
+ </Tooltip>
54
+ </View>
55
+
56
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
57
+ <Tooltip label="Left" placement="left">
58
+ <Button variant="outline" size="sm">
59
+ <ButtonText>Left</ButtonText>
60
+ </Button>
61
+ </Tooltip>
62
+ <Tooltip label="Right" placement="right">
63
+ <Button variant="outline" size="sm">
64
+ <ButtonText>Right</ButtonText>
65
+ </Button>
66
+ </Tooltip>
67
+ </View>
68
+
69
+ <View style={{ flexDirection: 'row', justifyContent: 'center', gap: 16 }}>
70
+ <Tooltip label="Bottom Start" placement="bottom-start">
71
+ <Button variant="outline" size="sm">
72
+ <ButtonText>Bottom Start</ButtonText>
73
+ </Button>
74
+ </Tooltip>
75
+ <Tooltip label="Bottom" placement="bottom">
76
+ <Button variant="outline" size="sm">
77
+ <ButtonText>Bottom</ButtonText>
78
+ </Button>
79
+ </Tooltip>
80
+ <Tooltip label="Bottom End" placement="bottom-end">
81
+ <Button variant="outline" size="sm">
82
+ <ButtonText>Bottom End</ButtonText>
83
+ </Button>
84
+ </Tooltip>
85
+ </View>
86
+ </View>
87
+ ),
88
+ }
89
+
90
+ export const WithDelay: Story = {
91
+ render: () => (
92
+ <View style={{ padding: 100 }}>
93
+ <Tooltip label="Appears after 500ms" openDelay={500}>
94
+ <Button>
95
+ <ButtonText>Hover (with delay)</ButtonText>
96
+ </Button>
97
+ </Tooltip>
98
+ </View>
99
+ ),
100
+ }
101
+
102
+ export const WithoutArrow: Story = {
103
+ render: () => (
104
+ <View style={{ padding: 100 }}>
105
+ <Tooltip label="No arrow" hasArrow={false}>
106
+ <Button>
107
+ <ButtonText>Hover me</ButtonText>
108
+ </Button>
109
+ </Tooltip>
110
+ </View>
111
+ ),
112
+ }
113
+
114
+ export const OnText: Story = {
115
+ render: () => (
116
+ <View style={{ padding: 100 }}>
117
+ <Text className="text-text-primary">
118
+ Hover over the{' '}
119
+ <Tooltip label="This is helpful information">
120
+ <Text className="text-brand-primary underline">underlined text</Text>
121
+ </Tooltip>
122
+ {' '}to see a tooltip.
123
+ </Text>
124
+ </View>
125
+ ),
126
+ }
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ function hoverTrigger(triggerText: string) {
7
+ const button = screen.getByText(triggerText)
8
+ // The Pressable wrapper is the parent element with tabindex
9
+ const pressable = button.closest('[tabindex]') ?? button
10
+ fireEvent.mouseEnter(pressable)
11
+ }
12
+
13
+ function unhoverTrigger(triggerText: string) {
14
+ const button = screen.getByText(triggerText)
15
+ const pressable = button.closest('[tabindex]') ?? button
16
+ fireEvent.mouseLeave(pressable)
17
+ }
18
+
19
+ describe('Tooltip', () => {
20
+ it('renders trigger children', () => {
21
+ render(
22
+ <Tooltip label="Tooltip text">
23
+ <button>Hover me</button>
24
+ </Tooltip>
25
+ )
26
+ expect(screen.getByText('Hover me')).toBeInTheDocument()
27
+ })
28
+
29
+ it('does not show tooltip content by default', () => {
30
+ render(
31
+ <Tooltip label="Hidden tooltip">
32
+ <button>Trigger</button>
33
+ </Tooltip>
34
+ )
35
+ expect(screen.queryByText('Hidden tooltip')).not.toBeInTheDocument()
36
+ })
37
+
38
+ it('shows tooltip on hover', () => {
39
+ render(
40
+ <Tooltip label="Visible tooltip">
41
+ <button>Hover me</button>
42
+ </Tooltip>
43
+ )
44
+
45
+ hoverTrigger('Hover me')
46
+ expect(screen.getByText('Visible tooltip')).toBeInTheDocument()
47
+ })
48
+
49
+ it('hides tooltip on hover out', () => {
50
+ render(
51
+ <Tooltip label="Tooltip text">
52
+ <button>Hover me</button>
53
+ </Tooltip>
54
+ )
55
+
56
+ hoverTrigger('Hover me')
57
+ expect(screen.getByText('Tooltip text')).toBeInTheDocument()
58
+
59
+ unhoverTrigger('Hover me')
60
+ expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument()
61
+ })
62
+
63
+ describe('rich content', () => {
64
+ it('renders content prop instead of label', () => {
65
+ render(
66
+ <Tooltip content={<span data-testid="rich">Rich content</span>}>
67
+ <button>Hover me</button>
68
+ </Tooltip>
69
+ )
70
+
71
+ hoverTrigger('Hover me')
72
+ expect(screen.getByTestId('rich')).toBeInTheDocument()
73
+ expect(screen.getByText('Rich content')).toBeInTheDocument()
74
+ })
75
+
76
+ it('content takes precedence over label', () => {
77
+ render(
78
+ <Tooltip label="Plain label" content={<em>Rich wins</em>}>
79
+ <button>Hover me</button>
80
+ </Tooltip>
81
+ )
82
+
83
+ hoverTrigger('Hover me')
84
+ expect(screen.getByText('Rich wins')).toBeInTheDocument()
85
+ expect(screen.queryByText('Plain label')).not.toBeInTheDocument()
86
+ })
87
+ })
88
+
89
+ describe('disabled state', () => {
90
+ it('does not show tooltip when isDisabled is true', () => {
91
+ render(
92
+ <Tooltip label="Disabled tooltip" isDisabled>
93
+ <button>Trigger</button>
94
+ </Tooltip>
95
+ )
96
+
97
+ hoverTrigger('Trigger')
98
+ expect(screen.queryByText('Disabled tooltip')).not.toBeInTheDocument()
99
+ })
100
+ })
101
+
102
+ describe('placement', () => {
103
+ it('renders with top placement (default)', () => {
104
+ render(
105
+ <Tooltip label="Top tooltip" placement="top">
106
+ <button>Trigger</button>
107
+ </Tooltip>
108
+ )
109
+
110
+ hoverTrigger('Trigger')
111
+ expect(screen.getByText('Top tooltip')).toBeInTheDocument()
112
+ })
113
+
114
+ it('renders with bottom placement', () => {
115
+ render(
116
+ <Tooltip label="Bottom tooltip" placement="bottom">
117
+ <button>Trigger</button>
118
+ </Tooltip>
119
+ )
120
+
121
+ hoverTrigger('Trigger')
122
+ expect(screen.getByText('Bottom tooltip')).toBeInTheDocument()
123
+ })
124
+
125
+ it('renders with left placement', () => {
126
+ render(
127
+ <Tooltip label="Left tooltip" placement="left">
128
+ <button>Trigger</button>
129
+ </Tooltip>
130
+ )
131
+
132
+ hoverTrigger('Trigger')
133
+ expect(screen.getByText('Left tooltip')).toBeInTheDocument()
134
+ })
135
+
136
+ it('renders with right placement', () => {
137
+ render(
138
+ <Tooltip label="Right tooltip" placement="right">
139
+ <button>Trigger</button>
140
+ </Tooltip>
141
+ )
142
+
143
+ hoverTrigger('Trigger')
144
+ expect(screen.getByText('Right tooltip')).toBeInTheDocument()
145
+ })
146
+
147
+ it('renders with top-start placement', () => {
148
+ render(
149
+ <Tooltip label="Top-start" placement="top-start">
150
+ <button>Trigger</button>
151
+ </Tooltip>
152
+ )
153
+
154
+ hoverTrigger('Trigger')
155
+ expect(screen.getByText('Top-start')).toBeInTheDocument()
156
+ })
157
+
158
+ it('renders with bottom-end placement', () => {
159
+ render(
160
+ <Tooltip label="Bottom-end" placement="bottom-end">
161
+ <button>Trigger</button>
162
+ </Tooltip>
163
+ )
164
+
165
+ hoverTrigger('Trigger')
166
+ expect(screen.getByText('Bottom-end')).toBeInTheDocument()
167
+ })
168
+ })
169
+
170
+ describe('arrow', () => {
171
+ it('renders with arrow by default', () => {
172
+ render(
173
+ <Tooltip label="With arrow">
174
+ <button>Trigger</button>
175
+ </Tooltip>
176
+ )
177
+
178
+ hoverTrigger('Trigger')
179
+ expect(screen.getByText('With arrow')).toBeInTheDocument()
180
+ })
181
+
182
+ it('renders without arrow when hasArrow is false', () => {
183
+ render(
184
+ <Tooltip label="No arrow" hasArrow={false}>
185
+ <button>Trigger</button>
186
+ </Tooltip>
187
+ )
188
+
189
+ hoverTrigger('Trigger')
190
+ expect(screen.getByText('No arrow')).toBeInTheDocument()
191
+ })
192
+ })
193
+
194
+ describe('portal mode', () => {
195
+ it('renders tooltip into document.body when usePortal is true', () => {
196
+ render(
197
+ <Tooltip label="Portal tooltip" usePortal>
198
+ <button>Hover me</button>
199
+ </Tooltip>
200
+ )
201
+
202
+ hoverTrigger('Hover me')
203
+ expect(screen.getByText('Portal tooltip')).toBeInTheDocument()
204
+ expect(screen.getByTestId('tooltip-portal')).toBeInTheDocument()
205
+ expect(screen.getByTestId('tooltip-portal').parentElement).toBe(
206
+ document.body
207
+ )
208
+ })
209
+
210
+ it('applies fixed positioning and z-index to portal tooltip', () => {
211
+ render(
212
+ <Tooltip label="Styled portal" usePortal>
213
+ <button>Hover me</button>
214
+ </Tooltip>
215
+ )
216
+
217
+ hoverTrigger('Hover me')
218
+ const portal = screen.getByTestId('tooltip-portal')
219
+ expect(portal.style.position).toBe('fixed')
220
+ expect(portal.style.zIndex).toBe('10000')
221
+ expect(portal.style.pointerEvents).toBe('none')
222
+ })
223
+
224
+ it('hides portal tooltip on hover out', () => {
225
+ render(
226
+ <Tooltip label="Portal hide" usePortal>
227
+ <button>Hover me</button>
228
+ </Tooltip>
229
+ )
230
+
231
+ hoverTrigger('Hover me')
232
+ expect(screen.getByText('Portal hide')).toBeInTheDocument()
233
+
234
+ unhoverTrigger('Hover me')
235
+ expect(screen.queryByText('Portal hide')).not.toBeInTheDocument()
236
+ })
237
+
238
+ it('does not render portal when usePortal is false (default)', () => {
239
+ render(
240
+ <Tooltip label="Inline tooltip">
241
+ <button>Hover me</button>
242
+ </Tooltip>
243
+ )
244
+
245
+ hoverTrigger('Hover me')
246
+ expect(screen.getByText('Inline tooltip')).toBeInTheDocument()
247
+ expect(screen.queryByTestId('tooltip-portal')).not.toBeInTheDocument()
248
+ })
249
+ })
250
+
251
+ describe('accessibility', () => {
252
+ it('has no accessibility violations', async () => {
253
+ const { container } = render(
254
+ <Tooltip label="Accessible tooltip">
255
+ <button>Trigger button</button>
256
+ </Tooltip>
257
+ )
258
+ const results = await axe(container)
259
+ expect(results).toHaveNoViolations()
260
+ })
261
+ })
262
+ })