@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.
- package/README.md +47 -1
- package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
- package/dist/chunk-UXVRPOME.mjs.map +1 -0
- package/dist/index.d.mts +101 -12
- package/dist/index.d.ts +101 -12
- package/dist/index.js +1267 -468
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +734 -112
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +86 -1
- package/dist/theme/index.d.ts +86 -1
- package/dist/theme/index.js +166 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/docs/WEB_SETUP.md +217 -0
- package/package.json +14 -2
- package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
- package/src/components/custom/Workout/RestTimer.tsx +172 -0
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
- package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
- package/src/components/custom/Workout/index.ts +2 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/index.ts +2 -2
- package/src/components/ui/avatar/Avatar.test.tsx +17 -0
- package/src/components/ui/avatar/Avatar.tsx +13 -5
- package/src/components/ui/badge/Badge.test.tsx +17 -0
- package/src/components/ui/badge/Badge.tsx +14 -0
- package/src/components/ui/badge/index.ts +1 -0
- package/src/components/ui/button/Button.tsx +55 -0
- package/src/components/ui/card/Card.test.tsx +29 -0
- package/src/components/ui/card/Card.tsx +32 -11
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
- package/src/components/ui/indicator/Indicator.test.tsx +55 -0
- package/src/components/ui/indicator/Indicator.tsx +73 -0
- package/src/components/ui/indicator/index.ts +2 -0
- package/src/components/ui/pill/Pill.stories.tsx +71 -0
- package/src/components/ui/pill/Pill.test.tsx +69 -0
- package/src/components/ui/pill/Pill.tsx +104 -0
- package/src/components/ui/pill/index.ts +2 -0
- package/src/components/ui/popover/Popover.test.tsx +144 -2
- package/src/components/ui/popover/Popover.tsx +81 -6
- package/src/components/ui/progress/Progress.test.tsx +29 -0
- package/src/components/ui/progress/Progress.tsx +54 -35
- package/src/components/ui/select/Select.test.tsx +20 -0
- package/src/components/ui/select/Select.tsx +9 -2
- package/src/components/ui/toast/index.ts +1 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +83 -0
- package/src/components/ui/tooltip/Tooltip.tsx +117 -26
- package/src/stories/PresetShowcase.stories.tsx +462 -0
- package/src/stories/ThemePresets.stories.tsx +85 -0
- package/src/theme/global.css +129 -1
- package/src/theme/index.ts +1 -0
- package/src/theme/presets/apply.ts +97 -0
- package/src/theme/presets/audiobook.ts +93 -0
- package/src/theme/presets/default.ts +6 -0
- package/src/theme/presets/index.ts +4 -0
- package/src/theme/presets/presets.test.ts +51 -0
- package/src/theme/presets/types.ts +76 -0
- package/src/utils/avatar-color.test.ts +30 -0
- package/src/utils/avatar-color.ts +20 -0
- package/src/web-jsx/jsx-dev-runtime.ts +28 -0
- package/src/web-jsx/jsx-runtime.ts +41 -0
- package/tailwind.config.js +53 -3
- 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
|
|
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 } =
|
|
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 } =
|
|
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
|
-
'
|
|
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={
|
|
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
|
-
{/*
|
|
205
|
+
{/* SVG implementation for web */}
|
|
195
206
|
<View
|
|
196
|
-
className={cn(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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'
|
|
@@ -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(
|