@titan-design/react-ui 0.1.1 → 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 (66) 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 +13 -2
  16. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  17. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  18. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  19. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  20. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  21. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  22. package/src/components/custom/Workout/index.ts +2 -0
  23. package/src/components/custom/index.ts +1 -0
  24. package/src/components/custom/stepper/index.ts +2 -2
  25. package/src/components/ui/avatar/Avatar.test.tsx +17 -0
  26. package/src/components/ui/avatar/Avatar.tsx +13 -5
  27. package/src/components/ui/badge/Badge.test.tsx +17 -0
  28. package/src/components/ui/badge/Badge.tsx +14 -0
  29. package/src/components/ui/badge/index.ts +1 -0
  30. package/src/components/ui/button/Button.tsx +55 -0
  31. package/src/components/ui/card/Card.test.tsx +29 -0
  32. package/src/components/ui/card/Card.tsx +32 -11
  33. package/src/components/ui/index.ts +2 -0
  34. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  35. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  36. package/src/components/ui/indicator/Indicator.tsx +73 -0
  37. package/src/components/ui/indicator/index.ts +2 -0
  38. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  39. package/src/components/ui/pill/Pill.test.tsx +69 -0
  40. package/src/components/ui/pill/Pill.tsx +104 -0
  41. package/src/components/ui/pill/index.ts +2 -0
  42. package/src/components/ui/popover/Popover.test.tsx +144 -2
  43. package/src/components/ui/popover/Popover.tsx +81 -6
  44. package/src/components/ui/progress/Progress.test.tsx +29 -0
  45. package/src/components/ui/progress/Progress.tsx +54 -35
  46. package/src/components/ui/select/Select.test.tsx +20 -0
  47. package/src/components/ui/select/Select.tsx +9 -2
  48. package/src/components/ui/toast/index.ts +1 -0
  49. package/src/components/ui/tooltip/Tooltip.test.tsx +83 -0
  50. package/src/components/ui/tooltip/Tooltip.tsx +117 -26
  51. package/src/stories/PresetShowcase.stories.tsx +462 -0
  52. package/src/stories/ThemePresets.stories.tsx +85 -0
  53. package/src/theme/global.css +129 -1
  54. package/src/theme/index.ts +1 -0
  55. package/src/theme/presets/apply.ts +97 -0
  56. package/src/theme/presets/audiobook.ts +93 -0
  57. package/src/theme/presets/default.ts +6 -0
  58. package/src/theme/presets/index.ts +4 -0
  59. package/src/theme/presets/presets.test.ts +51 -0
  60. package/src/theme/presets/types.ts +76 -0
  61. package/src/utils/avatar-color.test.ts +30 -0
  62. package/src/utils/avatar-color.ts +20 -0
  63. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  64. package/src/web-jsx/jsx-runtime.ts +41 -0
  65. package/tailwind.config.js +53 -3
  66. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { View, Text } from 'react-native'
5
+ import { SupersetWrapper } from './SupersetWrapper'
6
+
7
+ describe('SupersetWrapper', () => {
8
+ describe('label', () => {
9
+ it('renders default label "SS"', () => {
10
+ render(
11
+ <SupersetWrapper>
12
+ <View />
13
+ </SupersetWrapper>,
14
+ )
15
+ expect(screen.getByTestId('superset-label')).toHaveTextContent('SS')
16
+ })
17
+
18
+ it('renders custom label', () => {
19
+ render(
20
+ <SupersetWrapper label="A1/A2">
21
+ <View />
22
+ </SupersetWrapper>,
23
+ )
24
+ expect(screen.getByTestId('superset-label')).toHaveTextContent('A1/A2')
25
+ })
26
+ })
27
+
28
+ describe('color', () => {
29
+ it('applies default color #FF7900 to border and label background', () => {
30
+ render(
31
+ <SupersetWrapper>
32
+ <View />
33
+ </SupersetWrapper>,
34
+ )
35
+ const wrapper = screen.getByTestId('superset-wrapper')
36
+ expect(wrapper).toHaveStyle({ borderLeftColor: '#FF7900' })
37
+
38
+ const label = screen.getByTestId('superset-label')
39
+ expect(label).toHaveStyle({ backgroundColor: '#FF7900' })
40
+ })
41
+
42
+ it('applies custom color to border and label background', () => {
43
+ render(
44
+ <SupersetWrapper color="#22C55E">
45
+ <View />
46
+ </SupersetWrapper>,
47
+ )
48
+ const wrapper = screen.getByTestId('superset-wrapper')
49
+ expect(wrapper).toHaveStyle({ borderLeftColor: '#22C55E' })
50
+
51
+ const label = screen.getByTestId('superset-label')
52
+ expect(label).toHaveStyle({ backgroundColor: '#22C55E' })
53
+ })
54
+ })
55
+
56
+ describe('children', () => {
57
+ it('renders children', () => {
58
+ render(
59
+ <SupersetWrapper>
60
+ <Text testID="child-1">Exercise A</Text>
61
+ <Text testID="child-2">Exercise B</Text>
62
+ </SupersetWrapper>,
63
+ )
64
+ expect(screen.getByTestId('child-1')).toHaveTextContent('Exercise A')
65
+ expect(screen.getByTestId('child-2')).toHaveTextContent('Exercise B')
66
+ })
67
+ })
68
+
69
+ describe('accessibility', () => {
70
+ it('has group role', () => {
71
+ render(
72
+ <SupersetWrapper>
73
+ <View />
74
+ </SupersetWrapper>,
75
+ )
76
+ expect(screen.getByRole('group')).toBeInTheDocument()
77
+ })
78
+
79
+ it('has correct accessibility label with default label', () => {
80
+ render(
81
+ <SupersetWrapper>
82
+ <View />
83
+ </SupersetWrapper>,
84
+ )
85
+ expect(screen.getByLabelText('Superset: SS')).toBeInTheDocument()
86
+ })
87
+
88
+ it('has correct accessibility label with custom label', () => {
89
+ render(
90
+ <SupersetWrapper label="A1/A2">
91
+ <View />
92
+ </SupersetWrapper>,
93
+ )
94
+ expect(screen.getByLabelText('Superset: A1/A2')).toBeInTheDocument()
95
+ })
96
+
97
+ it('has no accessibility violations', async () => {
98
+ const { container } = render(
99
+ <SupersetWrapper>
100
+ <Text>Exercise A</Text>
101
+ <Text>Exercise B</Text>
102
+ </SupersetWrapper>,
103
+ )
104
+ const results = await axe(container)
105
+ expect(results).toHaveNoViolations()
106
+ })
107
+ })
108
+
109
+ describe('label positioning', () => {
110
+ it('positions label absolutely', () => {
111
+ render(
112
+ <SupersetWrapper>
113
+ <View />
114
+ </SupersetWrapper>,
115
+ )
116
+ const label = screen.getByTestId('superset-label')
117
+ expect(label).toHaveStyle({ position: 'absolute' })
118
+ })
119
+
120
+ it('positions label at top -1 and left -3', () => {
121
+ render(
122
+ <SupersetWrapper>
123
+ <View />
124
+ </SupersetWrapper>,
125
+ )
126
+ const label = screen.getByTestId('superset-label')
127
+ expect(label).toHaveStyle({ top: '-1px', left: '-3px' })
128
+ })
129
+ })
130
+ })
@@ -0,0 +1,63 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import React from 'react'
3
+ import { View, Text } from 'react-native'
4
+
5
+ export interface SupersetWrapperProps {
6
+ label?: string
7
+ color?: string
8
+ children: React.ReactNode
9
+ syncExpansion?: boolean
10
+ }
11
+
12
+ const DEFAULTS = {
13
+ label: 'SS',
14
+ color: '#FF7900',
15
+ bgBase: '#101010',
16
+ } as const
17
+
18
+ export function SupersetWrapper({
19
+ label = DEFAULTS.label,
20
+ color = DEFAULTS.color,
21
+ children,
22
+ }: SupersetWrapperProps) {
23
+ return (
24
+ <View
25
+ style={{
26
+ position: 'relative',
27
+ borderLeftWidth: 3,
28
+ borderLeftColor: color,
29
+ paddingLeft: 8,
30
+ marginHorizontal: 12,
31
+ marginBottom: 8,
32
+ overflow: 'visible',
33
+ }}
34
+ accessibilityRole={"group" as any}
35
+ accessibilityLabel={`Superset: ${label}`}
36
+ testID="superset-wrapper"
37
+ >
38
+ <Text
39
+ style={{
40
+ position: 'absolute',
41
+ top: -1,
42
+ left: -3,
43
+ fontSize: 9,
44
+ fontFamily: 'Inter, sans-serif',
45
+ fontWeight: '700',
46
+ backgroundColor: color,
47
+ color: DEFAULTS.bgBase,
48
+ paddingVertical: 2,
49
+ paddingHorizontal: 6,
50
+ borderBottomRightRadius: 4,
51
+ letterSpacing: 0.5,
52
+ zIndex: 2,
53
+ }}
54
+ testID="superset-label"
55
+ >
56
+ {label}
57
+ </Text>
58
+ <View style={{ gap: 2 }} testID="superset-children">
59
+ {children}
60
+ </View>
61
+ </View>
62
+ )
63
+ }
@@ -0,0 +1,2 @@
1
+ export { RestTimer, type RestTimerProps } from './RestTimer'
2
+ export { SupersetWrapper, type SupersetWrapperProps } from './SupersetWrapper'
@@ -6,3 +6,4 @@ export * from './EmptyState'
6
6
  export * from './stepper'
7
7
  export * from './DateTime'
8
8
  export * from './Metric'
9
+ export * from './Workout'
@@ -1,7 +1,7 @@
1
- export { Stepper, Step, StepIndicator, StepLabel, StepContent } from './Stepper'
1
+ export { Stepper, Step as StepperStep, StepIndicator, StepLabel, StepContent } from './Stepper'
2
2
  export type {
3
3
  StepperProps,
4
- StepProps,
4
+ StepProps as StepperStepProps,
5
5
  StepIndicatorProps,
6
6
  StepLabelProps,
7
7
  StepContentProps,
@@ -54,6 +54,23 @@ describe('Avatar', () => {
54
54
  expect(screen.getByRole('img')).toBeInTheDocument()
55
55
  })
56
56
 
57
+ describe('colorFromName', () => {
58
+ it('renders initials from colorFromName', () => {
59
+ render(<Avatar colorFromName="John Doe" />)
60
+ expect(screen.getByText('JD')).toBeInTheDocument()
61
+ })
62
+
63
+ it('generates deterministic color from name', () => {
64
+ const { container } = render(<Avatar colorFromName="Alice" />)
65
+ expect(container.firstChild).toBeInTheDocument()
66
+ })
67
+
68
+ it('colorFromName fallback is overridden by explicit fallback', () => {
69
+ render(<Avatar colorFromName="John Doe" fallback="XX" />)
70
+ expect(screen.getByText('XX')).toBeInTheDocument()
71
+ })
72
+ })
73
+
57
74
  describe('AvatarBadge', () => {
58
75
  it('renders with default success color', () => {
59
76
  const { container } = render(<AvatarBadge />)
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  import { View, Text, Image, type ViewProps, type ImageSourcePropType } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
+ import { avatarColor, getInitials } from '../../../utils/avatar-color'
4
5
 
5
6
  export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
6
7
 
@@ -13,6 +14,8 @@ export interface AvatarProps extends ViewProps {
13
14
  fallback?: string
14
15
  /** Alt text for accessibility */
15
16
  alt?: string
17
+ /** Generate background color and initials from a name */
18
+ colorFromName?: string
16
19
  /** Additional className */
17
20
  className?: string
18
21
  }
@@ -48,22 +51,27 @@ export function Avatar({
48
51
  source,
49
52
  fallback,
50
53
  alt,
54
+ colorFromName,
51
55
  className,
52
56
  ...props
53
57
  }: AvatarProps) {
54
58
  const styles = sizeStyles[size]
55
59
  const hasImage = source !== undefined
56
60
 
61
+ const resolvedFallback = fallback ?? (colorFromName ? getInitials(colorFromName) : undefined)
62
+ const nameColor = colorFromName ? avatarColor(colorFromName) : undefined
63
+
57
64
  return (
58
65
  <View
59
66
  accessibilityRole="image"
60
- accessibilityLabel={alt || fallback || 'Avatar'}
67
+ accessibilityLabel={alt || resolvedFallback || 'Avatar'}
61
68
  className={cn(
62
69
  'items-center justify-center rounded-full overflow-hidden',
63
- 'bg-border-strong',
70
+ !nameColor && 'bg-border-strong',
64
71
  styles.container,
65
72
  className
66
73
  )}
74
+ style={nameColor ? { backgroundColor: nameColor } : undefined}
67
75
  {...props}
68
76
  >
69
77
  {hasImage ? (
@@ -72,9 +80,9 @@ export function Avatar({
72
80
  className={cn('rounded-full', styles.image)}
73
81
  accessibilityLabel={alt}
74
82
  />
75
- ) : fallback ? (
76
- <Text className={cn('font-semibold text-text-primary', styles.text)}>
77
- {fallback}
83
+ ) : resolvedFallback ? (
84
+ <Text className={cn('font-semibold text-text-inverse', styles.text)}>
85
+ {resolvedFallback}
78
86
  </Text>
79
87
  ) : (
80
88
  <View className="w-full h-full bg-surface-raised" />
@@ -72,6 +72,23 @@ describe('Badge', () => {
72
72
  })
73
73
  })
74
74
 
75
+ describe('dot indicator', () => {
76
+ it('renders dot when dot prop is true', () => {
77
+ const { container } = render(<Badge dot color="success">Active</Badge>)
78
+ expect(container.firstChild).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders dot with explicit dotColor', () => {
82
+ const { container } = render(<Badge dot dotColor="warning">Pending</Badge>)
83
+ expect(container.firstChild).toBeInTheDocument()
84
+ })
85
+
86
+ it('does not render dot by default', () => {
87
+ const { container } = render(<Badge>No dot</Badge>)
88
+ expect(container.firstChild).toBeInTheDocument()
89
+ })
90
+ })
91
+
75
92
  describe('accessibility', () => {
76
93
  it('has no accessibility violations', async () => {
77
94
  const { container } = render(
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  import { View, Text, type ViewProps } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
+ import { Indicator, type IndicatorColor } from '../indicator'
4
5
 
5
6
  export type BadgeVariant = 'solid' | 'subtle' | 'outline'
6
7
  export type BadgeColor = 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'
@@ -13,6 +14,10 @@ export interface BadgeProps extends ViewProps {
13
14
  color?: BadgeColor
14
15
  /** Size */
15
16
  size?: BadgeSize
17
+ /** Show a leading indicator dot */
18
+ dot?: boolean
19
+ /** Color for the dot indicator (defaults to match badge color) */
20
+ dotColor?: IndicatorColor
16
21
  /** Additional className */
17
22
  className?: string
18
23
  children?: React.ReactNode
@@ -65,6 +70,8 @@ export function Badge({
65
70
  variant = 'subtle',
66
71
  color = 'default',
67
72
  size = 'md',
73
+ dot,
74
+ dotColor,
68
75
  className,
69
76
  children,
70
77
  ...props
@@ -79,6 +86,13 @@ export function Badge({
79
86
  )}
80
87
  {...props}
81
88
  >
89
+ {dot && (
90
+ <Indicator
91
+ size="xs"
92
+ color={dotColor ?? (color === 'default' || color === 'secondary' ? 'default' : color as IndicatorColor)}
93
+ className="mr-0.5"
94
+ />
95
+ )}
82
96
  {typeof children === 'string' ? (
83
97
  <Text className="text-inherit font-medium">{children}</Text>
84
98
  ) : (
@@ -1,2 +1,3 @@
1
1
  export { Badge, BadgeText } from './Badge'
2
2
  export type { BadgeProps, BadgeTextProps, BadgeVariant, BadgeColor, BadgeSize } from './Badge'
3
+ export type { IndicatorColor } from '../indicator'
@@ -7,6 +7,52 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link'
7
7
  export type ButtonSize = 'sm' | 'md' | 'lg'
8
8
  export type ButtonColor = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'
9
9
 
10
+ /** Inline color map for RNW where Tailwind text classes get dropped */
11
+ const textColorMap: Record<ButtonVariant, Record<ButtonColor, string>> = {
12
+ solid: {
13
+ primary: '#FFFFFF',
14
+ secondary: '#FFFFFF',
15
+ success: '#FFFFFF',
16
+ error: '#FFFFFF',
17
+ warning: '#FFFFFF',
18
+ info: '#FFFFFF',
19
+ },
20
+ outline: {
21
+ primary: semanticColorsDark['brand-primary'],
22
+ secondary: semanticColorsDark['brand-secondary'],
23
+ success: semanticColorsDark['status-success'],
24
+ error: semanticColorsDark['status-error'],
25
+ warning: semanticColorsDark['status-warning'],
26
+ info: semanticColorsDark['status-info'],
27
+ },
28
+ ghost: {
29
+ primary: semanticColorsDark['brand-primary'],
30
+ secondary: semanticColorsDark['brand-secondary'],
31
+ success: semanticColorsDark['status-success'],
32
+ error: semanticColorsDark['status-error'],
33
+ warning: semanticColorsDark['status-warning'],
34
+ info: semanticColorsDark['status-info'],
35
+ },
36
+ link: {
37
+ primary: semanticColorsDark['brand-primary'],
38
+ secondary: semanticColorsDark['brand-secondary'],
39
+ success: semanticColorsDark['status-success'],
40
+ error: semanticColorsDark['status-error'],
41
+ warning: semanticColorsDark['status-warning'],
42
+ info: semanticColorsDark['status-info'],
43
+ },
44
+ }
45
+
46
+ /** Inline border color map for outline variant */
47
+ const borderColorMap: Record<ButtonColor, string> = {
48
+ primary: semanticColorsDark['brand-primary'],
49
+ secondary: semanticColorsDark['brand-secondary'],
50
+ success: semanticColorsDark['status-success'],
51
+ error: semanticColorsDark['status-error'],
52
+ warning: semanticColorsDark['status-warning'],
53
+ info: semanticColorsDark['status-info'],
54
+ }
55
+
10
56
  export interface ButtonProps extends Omit<PressableProps, 'children'> {
11
57
  /** Visual style variant */
12
58
  variant?: ButtonVariant
@@ -151,6 +197,13 @@ export const Button = forwardRef<View, ButtonProps>(function Button(
151
197
  ) {
152
198
  const disabled = isDisabled || isLoading
153
199
 
200
+ const inlineStyle: Record<string, string> = {
201
+ color: textColorMap[variant][color],
202
+ }
203
+ if (variant === 'outline') {
204
+ inlineStyle.borderColor = borderColorMap[color]
205
+ }
206
+
154
207
  return (
155
208
  <Pressable
156
209
  ref={ref}
@@ -172,6 +225,7 @@ export const Button = forwardRef<View, ButtonProps>(function Button(
172
225
  variant === 'link' && 'px-0 py-0 min-h-0',
173
226
  className
174
227
  )}
228
+ style={inlineStyle}
175
229
  {...props}
176
230
  >
177
231
  {isLoading && (
@@ -188,6 +242,7 @@ export const Button = forwardRef<View, ButtonProps>(function Button(
188
242
  textSizeStyles[size],
189
243
  textStyles[variant][color]
190
244
  )}
245
+ style={{ color: textColorMap[variant][color] }}
191
246
  >
192
247
  {loadingText}
193
248
  </Text>
@@ -216,6 +216,35 @@ describe('Card', () => {
216
216
  })
217
217
  })
218
218
 
219
+ describe('accent and subtle variants', () => {
220
+ it('renders accent variant', () => {
221
+ const { container } = render(
222
+ <Card variant="accent">
223
+ <CardContent>Accent card</CardContent>
224
+ </Card>
225
+ )
226
+ expect(container.firstChild).toBeInTheDocument()
227
+ })
228
+
229
+ it('renders subtle variant', () => {
230
+ const { container } = render(
231
+ <Card variant="subtle">
232
+ <CardContent>Subtle card</CardContent>
233
+ </Card>
234
+ )
235
+ expect(container.firstChild).toBeInTheDocument()
236
+ })
237
+
238
+ it('renders accent variant with custom accentColor and accentWidth', () => {
239
+ const { container } = render(
240
+ <Card variant="accent" accentColor="#FF0000" accentWidth={5}>
241
+ <CardContent>Custom accent</CardContent>
242
+ </Card>
243
+ )
244
+ expect(container.firstChild).toBeInTheDocument()
245
+ })
246
+ })
247
+
219
248
  describe('custom styling', () => {
220
249
  it('applies custom borderColor', () => {
221
250
  const { container } = render(
@@ -10,7 +10,7 @@ import {
10
10
  } from '../../../theme'
11
11
  import { useTheme } from '../../../utils/useTheme'
12
12
 
13
- export type CardVariant = 'elevated' | 'outline' | 'filled'
13
+ export type CardVariant = 'elevated' | 'outline' | 'filled' | 'accent' | 'subtle'
14
14
  export type CardElevation = 1 | 2 | 3 // subtle, standard, prominent
15
15
 
16
16
  export interface CardProps extends ViewProps {
@@ -28,6 +28,10 @@ export interface CardProps extends ViewProps {
28
28
  borderColor?: string
29
29
  /** Custom background color (hex, rgb, or CSS color). Useful for colored cards. */
30
30
  bgColor?: string
31
+ /** Accent stripe color for accent variant (CSS color or hex) */
32
+ accentColor?: string
33
+ /** Accent stripe width for accent variant in pixels (default: 3) */
34
+ accentWidth?: number
31
35
  /** Additional className */
32
36
  className?: string
33
37
  children?: React.ReactNode
@@ -43,6 +47,8 @@ const variantStyles: Record<CardVariant, string> = {
43
47
  elevated: '', // Will be set dynamically via elevation system
44
48
  outline: 'border-2 border-border-strong', // Thicker border with stronger contrast
45
49
  filled: '', // Will be set dynamically via elevation system
50
+ accent: 'border border-border-default',
51
+ subtle: 'border border-border-subtle',
46
52
  }
47
53
 
48
54
  /**
@@ -75,6 +81,8 @@ export function Card({
75
81
  onPress,
76
82
  borderColor,
77
83
  bgColor,
84
+ accentColor,
85
+ accentWidth,
78
86
  className,
79
87
  children,
80
88
  style,
@@ -91,8 +99,8 @@ export function Card({
91
99
  const elevationLevel = useMemo(() => {
92
100
  const validated = getValidatedElevation('card', elevation as ElevationLevel)
93
101
  // Map variant to elevation if needed
94
- if (variant === 'outline') {
95
- return 1 as ElevationLevel // Outline uses subtle elevation
102
+ if (variant === 'outline' || variant === 'accent' || variant === 'subtle') {
103
+ return 1 as ElevationLevel // These border variants use subtle elevation
96
104
  }
97
105
  return validated
98
106
  }, [variant, elevation])
@@ -103,9 +111,13 @@ export function Card({
103
111
  // Calculate surface color and shadow from elevation
104
112
  const surfaceColor = useMemo(() => {
105
113
  // Outline variant in light mode uses a specific off-white for clear visibility
106
- if (variant === 'outline' && theme === 'light') {
114
+ if ((variant === 'outline' || variant === 'accent') && theme === 'light') {
107
115
  return '#FAFAFA' // Matches --color-surface-elevated in light mode
108
116
  }
117
+ // Subtle variant uses the base surface color without elevation lift
118
+ if (variant === 'subtle') {
119
+ return getElevationSurface(baseColor, 0 as ElevationLevel, theme)
120
+ }
109
121
  return getElevationSurface(baseColor, elevationLevel, theme)
110
122
  }, [baseColor, elevationLevel, theme, variant])
111
123
 
@@ -148,8 +160,8 @@ export function Card({
148
160
  const baseClassName = cn(
149
161
  'rounded-lg overflow-hidden relative',
150
162
  // Apply variant styles, but only use default border color if no custom borderColor
151
- variant === 'outline'
152
- ? borderColor
163
+ variant === 'outline'
164
+ ? borderColor
153
165
  ? 'border-2' // Just the border width, color via style
154
166
  : variantStyles[variant] // Full variant styles including color
155
167
  : variantStyles[variant],
@@ -160,22 +172,31 @@ export function Card({
160
172
  className
161
173
  )
162
174
 
175
+ // Accent variant: left stripe via borderLeft override
176
+ const accentStyle = useMemo(() => variant === 'accent' ? {
177
+ borderLeftWidth: accentWidth ?? 3,
178
+ borderLeftColor: accentColor ?? 'var(--color-brand-primary)',
179
+ } : {}, [variant, accentWidth, accentColor])
180
+
163
181
  // Merge styles: background color from elevation + shadow style + custom colors + custom style
164
182
  const mergedStyle = useMemo(() => {
165
183
  const elevationStyle: Record<string, any> = {
166
184
  backgroundColor: bgColor || surfaceColor,
185
+ borderRadius: 8,
186
+ overflow: 'hidden' as const,
167
187
  ...shadowStyle,
188
+ ...accentStyle,
168
189
  }
169
-
190
+
170
191
  // Add custom border color if provided
171
192
  if (borderColor) {
172
193
  elevationStyle.borderColor = borderColor
173
194
  }
174
-
195
+
175
196
  if (!style) return elevationStyle
176
- // Merge styles, with elevation style taking precedence for background/shadow
177
- return [style, elevationStyle]
178
- }, [style, surfaceColor, shadowStyle, borderColor, bgColor])
197
+ // User style takes precedence over elevation defaults (e.g. maxWidth, custom bg)
198
+ return [elevationStyle, style]
199
+ }, [style, surfaceColor, shadowStyle, borderColor, bgColor, accentStyle])
179
200
 
180
201
  if (isClickable) {
181
202
  return (
@@ -34,3 +34,5 @@ export * from './data-row'
34
34
  export * from './icon-box'
35
35
  export * from './surface'
36
36
  export * from './list-item'
37
+ export * from './indicator'
38
+ export * from './pill'
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Indicator } from './Indicator'
4
+
5
+ const meta: Meta<typeof Indicator> = {
6
+ title: 'Components/Indicator',
7
+ component: Indicator,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ size: { control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
11
+ color: { control: 'select', options: ['default', 'primary', 'success', 'error', 'warning', 'info'] },
12
+ glow: { control: 'boolean' },
13
+ ring: { control: 'boolean' },
14
+ },
15
+ }
16
+ export default meta
17
+ type Story = StoryObj<typeof Indicator>
18
+
19
+ export const Default: Story = { args: { color: 'primary', size: 'md' } }
20
+
21
+ export const AllSizes: Story = {
22
+ render: () => (
23
+ <View className="flex-row gap-4 items-center">
24
+ <Indicator size="xs" color="primary" />
25
+ <Indicator size="sm" color="primary" />
26
+ <Indicator size="md" color="primary" />
27
+ <Indicator size="lg" color="primary" />
28
+ </View>
29
+ ),
30
+ }
31
+
32
+ export const AllColors: Story = {
33
+ render: () => (
34
+ <View className="flex-row gap-4 items-center">
35
+ <Indicator size="md" color="default" />
36
+ <Indicator size="md" color="primary" />
37
+ <Indicator size="md" color="success" />
38
+ <Indicator size="md" color="error" />
39
+ <Indicator size="md" color="warning" />
40
+ <Indicator size="md" color="info" />
41
+ </View>
42
+ ),
43
+ }
44
+
45
+ export const WithGlow: Story = {
46
+ render: () => (
47
+ <View className="flex-row gap-6 items-center p-4 bg-surface-base">
48
+ <Indicator size="md" color="success" glow />
49
+ <Indicator size="md" color="error" glow />
50
+ <Indicator size="md" color="warning" glow />
51
+ <Indicator size="md" color="primary" glow />
52
+ </View>
53
+ ),
54
+ }
55
+
56
+ export const WithRing: Story = {
57
+ render: () => (
58
+ <View className="flex-row gap-4 items-center">
59
+ <Indicator size="md" color="success" ring />
60
+ <Indicator size="md" color="error" ring />
61
+ <Indicator size="lg" color="primary" ring />
62
+ </View>
63
+ ),
64
+ }
65
+
66
+ export const CustomColor: Story = {
67
+ render: () => (
68
+ <View className="flex-row gap-4 items-center">
69
+ <Indicator size="md" customColor="#FF6B6B" />
70
+ <Indicator size="md" customColor="#4ECDC4" />
71
+ <Indicator size="md" customColor="#45B7D1" glow />
72
+ </View>
73
+ ),
74
+ }