@titan-design/react-ui 0.1.1 → 0.2.6

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 (67) 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/docs/WEB_SETUP.md +217 -0
  16. package/package.json +14 -2
  17. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  18. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  19. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  20. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  21. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  22. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  23. package/src/components/custom/Workout/index.ts +2 -0
  24. package/src/components/custom/index.ts +1 -0
  25. package/src/components/custom/stepper/index.ts +2 -2
  26. package/src/components/ui/avatar/Avatar.test.tsx +17 -0
  27. package/src/components/ui/avatar/Avatar.tsx +13 -5
  28. package/src/components/ui/badge/Badge.test.tsx +17 -0
  29. package/src/components/ui/badge/Badge.tsx +14 -0
  30. package/src/components/ui/badge/index.ts +1 -0
  31. package/src/components/ui/button/Button.tsx +55 -0
  32. package/src/components/ui/card/Card.test.tsx +29 -0
  33. package/src/components/ui/card/Card.tsx +32 -11
  34. package/src/components/ui/index.ts +2 -0
  35. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  36. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  37. package/src/components/ui/indicator/Indicator.tsx +73 -0
  38. package/src/components/ui/indicator/index.ts +2 -0
  39. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  40. package/src/components/ui/pill/Pill.test.tsx +69 -0
  41. package/src/components/ui/pill/Pill.tsx +104 -0
  42. package/src/components/ui/pill/index.ts +2 -0
  43. package/src/components/ui/popover/Popover.test.tsx +144 -2
  44. package/src/components/ui/popover/Popover.tsx +81 -6
  45. package/src/components/ui/progress/Progress.test.tsx +29 -0
  46. package/src/components/ui/progress/Progress.tsx +54 -35
  47. package/src/components/ui/select/Select.test.tsx +20 -0
  48. package/src/components/ui/select/Select.tsx +9 -2
  49. package/src/components/ui/toast/index.ts +1 -0
  50. package/src/components/ui/tooltip/Tooltip.test.tsx +83 -0
  51. package/src/components/ui/tooltip/Tooltip.tsx +117 -26
  52. package/src/stories/PresetShowcase.stories.tsx +462 -0
  53. package/src/stories/ThemePresets.stories.tsx +85 -0
  54. package/src/theme/global.css +129 -1
  55. package/src/theme/index.ts +1 -0
  56. package/src/theme/presets/apply.ts +97 -0
  57. package/src/theme/presets/audiobook.ts +93 -0
  58. package/src/theme/presets/default.ts +6 -0
  59. package/src/theme/presets/index.ts +4 -0
  60. package/src/theme/presets/presets.test.ts +51 -0
  61. package/src/theme/presets/types.ts +76 -0
  62. package/src/utils/avatar-color.test.ts +30 -0
  63. package/src/utils/avatar-color.ts +20 -0
  64. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  65. package/src/web-jsx/jsx-runtime.ts +41 -0
  66. package/tailwind.config.js +53 -3
  67. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, act } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import {
5
5
  Popover,
@@ -240,6 +240,148 @@ describe('Popover', () => {
240
240
  })
241
241
  })
242
242
 
243
+ describe('hover mode', () => {
244
+ beforeEach(() => {
245
+ vi.useFakeTimers()
246
+ })
247
+
248
+ afterEach(() => {
249
+ vi.useRealTimers()
250
+ })
251
+
252
+ it('opens popover on mouseEnter of trigger', () => {
253
+ render(
254
+ <Popover triggerMode="hover">
255
+ <PopoverTrigger>
256
+ <button>Hover me</button>
257
+ </PopoverTrigger>
258
+ <PopoverContent>
259
+ <span>Hover content</span>
260
+ </PopoverContent>
261
+ </Popover>
262
+ )
263
+
264
+ expect(screen.queryByText('Hover content')).not.toBeInTheDocument()
265
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
266
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
267
+ })
268
+
269
+ it('closes popover on mouseLeave after closeDelay', () => {
270
+ render(
271
+ <Popover triggerMode="hover" closeDelay={200}>
272
+ <PopoverTrigger>
273
+ <button>Hover me</button>
274
+ </PopoverTrigger>
275
+ <PopoverContent>
276
+ <span>Hover content</span>
277
+ </PopoverContent>
278
+ </Popover>
279
+ )
280
+
281
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
282
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
283
+
284
+ fireEvent.mouseLeave(screen.getByText('Hover me'))
285
+ // Still visible before delay expires
286
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
287
+
288
+ act(() => {
289
+ vi.advanceTimersByTime(200)
290
+ })
291
+ expect(screen.queryByText('Hover content')).not.toBeInTheDocument()
292
+ })
293
+
294
+ it('cancels close when hovering back onto trigger', () => {
295
+ render(
296
+ <Popover triggerMode="hover" closeDelay={150}>
297
+ <PopoverTrigger>
298
+ <button>Hover me</button>
299
+ </PopoverTrigger>
300
+ <PopoverContent>
301
+ <span>Hover content</span>
302
+ </PopoverContent>
303
+ </Popover>
304
+ )
305
+
306
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
307
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
308
+
309
+ fireEvent.mouseLeave(screen.getByText('Hover me'))
310
+ act(() => {
311
+ vi.advanceTimersByTime(50)
312
+ })
313
+
314
+ // Re-enter trigger before delay expires
315
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
316
+ act(() => {
317
+ vi.advanceTimersByTime(150)
318
+ })
319
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
320
+ })
321
+
322
+ it('cancels close when hovering onto content', () => {
323
+ render(
324
+ <Popover triggerMode="hover" closeDelay={150}>
325
+ <PopoverTrigger>
326
+ <button>Hover me</button>
327
+ </PopoverTrigger>
328
+ <PopoverContent>
329
+ <span>Hover content</span>
330
+ </PopoverContent>
331
+ </Popover>
332
+ )
333
+
334
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
335
+ fireEvent.mouseLeave(screen.getByText('Hover me'))
336
+
337
+ // Hover onto the content text (inside PopoverContent)
338
+ fireEvent.mouseEnter(screen.getByText('Hover content'))
339
+ act(() => {
340
+ vi.advanceTimersByTime(300)
341
+ })
342
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
343
+ })
344
+
345
+ it('still closes on click outside in hover mode', () => {
346
+ render(
347
+ <Popover triggerMode="hover">
348
+ <PopoverTrigger>
349
+ <button>Hover me</button>
350
+ </PopoverTrigger>
351
+ <PopoverContent>
352
+ <span>Hover content</span>
353
+ </PopoverContent>
354
+ </Popover>
355
+ )
356
+
357
+ fireEvent.mouseEnter(screen.getByText('Hover me'))
358
+ expect(screen.getByText('Hover content')).toBeInTheDocument()
359
+
360
+ // The backdrop is the click-outside mechanism
361
+ fireEvent.click(screen.getByText('Hover content').parentElement!.previousSibling as Element)
362
+ expect(screen.queryByText('Hover content')).not.toBeInTheDocument()
363
+ })
364
+
365
+ it('defaults to click mode when triggerMode is not specified', () => {
366
+ render(
367
+ <Popover>
368
+ <PopoverTrigger>
369
+ <button>Click me</button>
370
+ </PopoverTrigger>
371
+ <PopoverContent>
372
+ <span>Click content</span>
373
+ </PopoverContent>
374
+ </Popover>
375
+ )
376
+
377
+ fireEvent.mouseEnter(screen.getByText('Click me'))
378
+ expect(screen.queryByText('Click content')).not.toBeInTheDocument()
379
+
380
+ fireEvent.click(screen.getByText('Click me'))
381
+ expect(screen.getByText('Click content')).toBeInTheDocument()
382
+ })
383
+ })
384
+
243
385
  describe('accessibility', () => {
244
386
  it('has no accessibility violations when closed', async () => {
245
387
  const { container } = render(
@@ -1,4 +1,4 @@
1
- import React, { useState, createContext, useContext } from 'react'
1
+ import React, { useState, useRef, useCallback, createContext, useContext } from 'react'
2
2
  import { View, Pressable, Modal, type ViewProps } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
4
 
@@ -8,12 +8,18 @@ interface PopoverContextType {
8
8
  isOpen: boolean
9
9
  setIsOpen: (open: boolean) => void
10
10
  placement: PopoverPlacement
11
+ triggerMode: 'click' | 'hover'
12
+ handleHoverIn: () => void
13
+ handleHoverOut: () => void
11
14
  }
12
15
 
13
16
  const PopoverContext = createContext<PopoverContextType>({
14
17
  isOpen: false,
15
18
  setIsOpen: () => {},
16
19
  placement: 'bottom',
20
+ triggerMode: 'click',
21
+ handleHoverIn: () => {},
22
+ handleHoverOut: () => {},
17
23
  })
18
24
 
19
25
  export interface PopoverProps extends ViewProps {
@@ -25,6 +31,10 @@ export interface PopoverProps extends ViewProps {
25
31
  onOpenChange?: (isOpen: boolean) => void
26
32
  /** Whether clicking outside closes the popover */
27
33
  closeOnClickOutside?: boolean
34
+ /** Trigger mode: 'click' opens on click, 'hover' opens on hover */
35
+ triggerMode?: 'click' | 'hover'
36
+ /** Delay in ms before closing in hover mode (default 150) */
37
+ closeDelay?: number
28
38
  /** Additional className */
29
39
  className?: string
30
40
  children?: React.ReactNode
@@ -48,22 +58,44 @@ export function Popover({
48
58
  isOpen: controlledIsOpen,
49
59
  onOpenChange,
50
60
  closeOnClickOutside = true,
61
+ triggerMode = 'click',
62
+ closeDelay = 150,
51
63
  className,
52
64
  children,
53
65
  ...props
54
66
  }: PopoverProps) {
55
67
  const [internalIsOpen, setInternalIsOpen] = useState(false)
56
68
  const isOpen = controlledIsOpen ?? internalIsOpen
69
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
57
70
 
58
- const setIsOpen = (open: boolean) => {
71
+ const setIsOpen = useCallback((open: boolean) => {
59
72
  if (controlledIsOpen === undefined) {
60
73
  setInternalIsOpen(open)
61
74
  }
62
75
  onOpenChange?.(open)
63
- }
76
+ }, [controlledIsOpen, onOpenChange])
77
+
78
+ const handleHoverIn = useCallback(() => {
79
+ if (triggerMode !== 'hover') return
80
+ if (closeTimerRef.current) {
81
+ clearTimeout(closeTimerRef.current)
82
+ closeTimerRef.current = null
83
+ }
84
+ setIsOpen(true)
85
+ }, [triggerMode, setIsOpen])
86
+
87
+ const handleHoverOut = useCallback(() => {
88
+ if (triggerMode !== 'hover') return
89
+ closeTimerRef.current = setTimeout(() => {
90
+ setIsOpen(false)
91
+ closeTimerRef.current = null
92
+ }, closeDelay)
93
+ }, [triggerMode, closeDelay, setIsOpen])
64
94
 
65
95
  return (
66
- <PopoverContext.Provider value={{ isOpen, setIsOpen, placement }}>
96
+ <PopoverContext.Provider
97
+ value={{ isOpen, setIsOpen, placement, triggerMode, handleHoverIn, handleHoverOut }}
98
+ >
67
99
  <View className={cn('relative', className)} {...props}>
68
100
  {children}
69
101
  </View>
@@ -80,7 +112,18 @@ export interface PopoverTriggerProps {
80
112
  * Trigger element for the popover.
81
113
  */
82
114
  export function PopoverTrigger({ children, className }: PopoverTriggerProps) {
83
- const { isOpen, setIsOpen } = useContext(PopoverContext)
115
+ const { isOpen, setIsOpen, triggerMode, handleHoverIn, handleHoverOut } =
116
+ useContext(PopoverContext)
117
+
118
+ const hoverProps =
119
+ triggerMode === 'hover'
120
+ ? {
121
+ onHoverIn: handleHoverIn,
122
+ onHoverOut: handleHoverOut,
123
+ onMouseEnter: handleHoverIn,
124
+ onMouseLeave: handleHoverOut,
125
+ }
126
+ : {}
84
127
 
85
128
  return (
86
129
  <Pressable
@@ -88,6 +131,7 @@ export function PopoverTrigger({ children, className }: PopoverTriggerProps) {
88
131
  accessibilityRole="button"
89
132
  accessibilityState={{ expanded: isOpen }}
90
133
  className={className}
134
+ {...hoverProps}
91
135
  >
92
136
  {children}
93
137
  </Pressable>
@@ -99,11 +143,19 @@ export interface PopoverContentProps {
99
143
  className?: string
100
144
  }
101
145
 
146
+ const SPACER_STYLES: Record<PopoverPlacement, React.CSSProperties> = {
147
+ top: { left: 0, right: 0, bottom: 0, height: 8, transform: 'translateY(100%)' },
148
+ bottom: { left: 0, right: 0, top: 0, height: 8, transform: 'translateY(-100%)' },
149
+ left: { top: 0, bottom: 0, right: 0, width: 8, transform: 'translateX(100%)' },
150
+ right: { top: 0, bottom: 0, left: 0, width: 8, transform: 'translateX(-100%)' },
151
+ }
152
+
102
153
  /**
103
154
  * Content container for the popover.
104
155
  */
105
156
  export function PopoverContent({ children, className }: PopoverContentProps) {
106
- const { isOpen, setIsOpen, placement } = useContext(PopoverContext)
157
+ const { isOpen, setIsOpen, placement, triggerMode, handleHoverIn, handleHoverOut } =
158
+ useContext(PopoverContext)
107
159
 
108
160
  if (!isOpen) return null
109
161
 
@@ -114,6 +166,16 @@ export function PopoverContent({ children, className }: PopoverContentProps) {
114
166
  right: 'left-full top-0 ml-2',
115
167
  }
116
168
 
169
+ const hoverProps =
170
+ triggerMode === 'hover'
171
+ ? {
172
+ onHoverIn: handleHoverIn,
173
+ onHoverOut: handleHoverOut,
174
+ onMouseEnter: handleHoverIn,
175
+ onMouseLeave: handleHoverOut,
176
+ }
177
+ : {}
178
+
117
179
  return (
118
180
  <>
119
181
  {/* Backdrop */}
@@ -131,7 +193,20 @@ export function PopoverContent({ children, className }: PopoverContentProps) {
131
193
  placementStyles[placement],
132
194
  className
133
195
  )}
196
+ {...hoverProps}
134
197
  >
198
+ {triggerMode === 'hover' && (
199
+ <div
200
+ style={{
201
+ position: 'absolute',
202
+ pointerEvents: 'auto',
203
+ background: 'transparent',
204
+ ...SPACER_STYLES[placement],
205
+ }}
206
+ onMouseEnter={handleHoverIn}
207
+ onMouseLeave={handleHoverOut}
208
+ />
209
+ )}
135
210
  {children}
136
211
  </View>
137
212
  </>
@@ -79,6 +79,23 @@ describe('Progress', () => {
79
79
  expect(container.firstChild).toBeInTheDocument()
80
80
  })
81
81
 
82
+ describe('trackWidth and customColor', () => {
83
+ it('supports fixed trackWidth', () => {
84
+ const { container } = render(<Progress value={50} trackWidth={60} />)
85
+ expect(container.firstChild).toBeInTheDocument()
86
+ })
87
+
88
+ it('supports customColor', () => {
89
+ const { container } = render(<Progress value={50} customColor="#FF6B6B" />)
90
+ expect(container.firstChild).toBeInTheDocument()
91
+ })
92
+
93
+ it('renders correctly without trackWidth or customColor', () => {
94
+ render(<Progress value={75} />)
95
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
96
+ })
97
+ })
98
+
82
99
  describe('CircularProgress', () => {
83
100
  it('renders correctly', () => {
84
101
  render(<CircularProgress value={50} />)
@@ -115,6 +132,18 @@ describe('Progress', () => {
115
132
  unmount()
116
133
  })
117
134
  })
135
+
136
+ it('renders SVG circles', () => {
137
+ const { container } = render(<CircularProgress value={50} />)
138
+ const circles = container.querySelectorAll('circle')
139
+ expect(circles.length).toBe(2) // track + progress
140
+ })
141
+
142
+ it('applies custom color to SVG progress circle', () => {
143
+ const { container } = render(<CircularProgress value={50} color="success" />)
144
+ const progressCircle = container.querySelectorAll('circle')[1]
145
+ expect(progressCircle?.getAttribute('stroke')).toBe('var(--color-status-success)')
146
+ })
118
147
  })
119
148
 
120
149
  describe('ProgressSteps', () => {
@@ -33,6 +33,10 @@ export interface ProgressProps extends ViewProps {
33
33
  formatValue?: (value: number, max: number) => string
34
34
  /** Label text */
35
35
  label?: string
36
+ /** Fixed track width in pixels (default: full width) */
37
+ trackWidth?: number
38
+ /** Custom hex color override for the fill bar */
39
+ customColor?: string
36
40
  /** Additional className */
37
41
  className?: string
38
42
  }
@@ -85,6 +89,8 @@ export function Progress({
85
89
  showValue = false,
86
90
  formatValue = (v, m) => `${Math.round((v / m) * 100)}%`,
87
91
  label,
92
+ trackWidth,
93
+ customColor,
88
94
  className,
89
95
  ...props
90
96
  }: ProgressProps) {
@@ -109,10 +115,12 @@ export function Progress({
109
115
  {/* Progress Track */}
110
116
  <View
111
117
  className={cn(
112
- 'w-full rounded-full overflow-hidden',
118
+ 'rounded-full overflow-hidden',
119
+ trackWidth ? undefined : 'w-full',
113
120
  trackColorStyles[color],
114
121
  sizeStyles[size]
115
122
  )}
123
+ style={trackWidth ? { width: trackWidth } : undefined}
116
124
  accessibilityRole="progressbar"
117
125
  accessibilityValue={{
118
126
  min: 0,
@@ -124,10 +132,13 @@ export function Progress({
124
132
  <View
125
133
  className={cn(
126
134
  'h-full rounded-full',
127
- colorStyles[color],
135
+ !customColor && colorStyles[color],
128
136
  isIndeterminate && 'animate-pulse w-1/3'
129
137
  )}
130
- style={isIndeterminate ? undefined : { width: `${percentage}%` }}
138
+ style={{
139
+ ...(isIndeterminate ? {} : { width: `${percentage}%` }),
140
+ ...(customColor ? { backgroundColor: customColor } : {}),
141
+ }}
131
142
  />
132
143
  </View>
133
144
  </View>
@@ -177,8 +188,8 @@ export function CircularProgress({
177
188
  const radius = (size - strokeWidth) / 2
178
189
  const circumference = 2 * Math.PI * radius
179
190
  const strokeDashoffset = circumference - (percentage / 100) * circumference
191
+ const center = size / 2
180
192
 
181
- // For web, we use SVG. For native, we use a simplified view-based approach.
182
193
  return (
183
194
  <View
184
195
  className={cn('items-center justify-center', className)}
@@ -191,41 +202,49 @@ export function CircularProgress({
191
202
  }}
192
203
  {...props}
193
204
  >
194
- {/* Background circle */}
205
+ {/* SVG implementation for web */}
195
206
  <View
196
- className={cn(
197
- 'absolute rounded-full border-4',
198
- 'border-border-default'
199
- )}
200
- style={{
201
- width: size,
202
- height: size,
203
- borderWidth: strokeWidth,
204
- }}
205
- />
206
-
207
- {/* Progress arc (simplified) */}
208
- <View
209
- className={cn(
210
- 'absolute rounded-full',
211
- colorStyles[color],
212
- isIndeterminate && 'animate-spin'
213
- )}
214
- style={{
215
- width: size,
216
- height: size,
217
- borderWidth: strokeWidth,
218
- borderColor: 'transparent',
219
- borderTopColor: colorVarMap[color],
220
- borderRightColor: percentage > 25 ? colorVarMap[color] : 'transparent',
221
- borderBottomColor: percentage > 50 ? colorVarMap[color] : 'transparent',
222
- borderLeftColor: percentage > 75 ? colorVarMap[color] : 'transparent',
223
- }}
224
- />
207
+ className={cn(isIndeterminate && 'animate-spin')}
208
+ style={{ width: size, height: size }}
209
+ >
210
+ <svg
211
+ width={size}
212
+ height={size}
213
+ viewBox={`0 0 ${size} ${size}`}
214
+ style={{ position: 'absolute' }}
215
+ >
216
+ {/* Background track */}
217
+ <circle
218
+ cx={center}
219
+ cy={center}
220
+ r={radius}
221
+ fill="none"
222
+ stroke="var(--color-border-default)"
223
+ strokeWidth={strokeWidth}
224
+ />
225
+ {/* Progress arc */}
226
+ <circle
227
+ cx={center}
228
+ cy={center}
229
+ r={radius}
230
+ fill="none"
231
+ stroke={colorVarMap[color]}
232
+ strokeWidth={strokeWidth}
233
+ strokeLinecap="round"
234
+ strokeDasharray={circumference}
235
+ strokeDashoffset={isIndeterminate ? circumference * 0.75 : strokeDashoffset}
236
+ transform={`rotate(-90 ${center} ${center})`}
237
+ style={{ transition: 'stroke-dashoffset 300ms cubic-bezier(0.22, 1, 0.36, 1)' }}
238
+ />
239
+ </svg>
240
+ </View>
225
241
 
226
242
  {/* Center value */}
227
243
  {showValue && !isIndeterminate && (
228
- <Text className="text-xs font-semibold text-text-primary">
244
+ <Text
245
+ className="text-xs font-semibold text-text-primary absolute"
246
+ style={{ fontSize: Math.max(size * 0.22, 10) }}
247
+ >
229
248
  {formatValue(value, max)}
230
249
  </Text>
231
250
  )}
@@ -141,6 +141,26 @@ describe('Select', () => {
141
141
  })
142
142
  })
143
143
 
144
+ describe('filled variant', () => {
145
+ it('renders with filled variant without errors', () => {
146
+ render(<Select options={defaultOptions} variant="filled" />)
147
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
148
+ })
149
+
150
+ it('renders with default variant when omitted', () => {
151
+ render(<Select options={defaultOptions} />)
152
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
153
+ })
154
+
155
+ it('supports selection in filled variant', () => {
156
+ const onChange = vi.fn()
157
+ render(<Select options={defaultOptions} variant="filled" onChange={onChange} />)
158
+ fireEvent.click(screen.getByRole('combobox'))
159
+ fireEvent.click(screen.getByText('Option 2'))
160
+ expect(onChange).toHaveBeenCalledWith('2')
161
+ })
162
+ })
163
+
144
164
  describe('accessibility', () => {
145
165
  it('has no accessibility violations', async () => {
146
166
  const { container } = render(
@@ -45,6 +45,8 @@ export interface SelectProps<T = string> extends ViewProps {
45
45
  isInvalid?: boolean
46
46
  /** Options to display */
47
47
  options: SelectOption<T>[]
48
+ /** Visual variant — use 'filled' on dark/elevated surfaces */
49
+ variant?: 'default' | 'filled'
48
50
  /** Additional className */
49
51
  className?: string
50
52
  }
@@ -81,6 +83,7 @@ export function Select<T extends string = string>({
81
83
  isDisabled = false,
82
84
  isInvalid = false,
83
85
  options,
86
+ variant = 'default',
84
87
  className,
85
88
  ...props
86
89
  }: SelectProps<T>) {
@@ -138,8 +141,12 @@ export function Select<T extends string = string>({
138
141
  accessibilityState={{ expanded: isOpen, disabled: isDisabled }}
139
142
  className={cn(
140
143
  'flex-row items-center justify-between px-4 py-2.5 rounded-md border',
141
- 'bg-surface-base',
142
- isInvalid ? 'border-border-input-error' : 'border-border-input',
144
+ variant === 'filled' ? 'bg-black/30' : 'bg-surface-base',
145
+ isInvalid
146
+ ? 'border-border-input-error'
147
+ : variant === 'filled'
148
+ ? 'border-border-subtle'
149
+ : 'border-border-input',
143
150
  !isDisabled && !isInvalid && 'web:hover:border-border-input-hover',
144
151
  isOpen && 'border-border-input-focus',
145
152
  isDisabled && 'opacity-50 cursor-not-allowed'
@@ -6,3 +6,4 @@ export type {
6
6
  ToastStatus,
7
7
  ToastPosition,
8
8
  } from './Toast'
9
+
@@ -60,6 +60,32 @@ describe('Tooltip', () => {
60
60
  expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument()
61
61
  })
62
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
+
63
89
  describe('disabled state', () => {
64
90
  it('does not show tooltip when isDisabled is true', () => {
65
91
  render(
@@ -165,6 +191,63 @@ describe('Tooltip', () => {
165
191
  })
166
192
  })
167
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
+
168
251
  describe('accessibility', () => {
169
252
  it('has no accessibility violations', async () => {
170
253
  const { container } = render(