@titan-design/react-ui 0.1.0 → 0.1.1
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/package.json +10 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
- package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
- package/src/components/custom/DateTime/DateTime.tsx +235 -0
- package/src/components/custom/DateTime/index.ts +2 -0
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
- package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
- package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
- package/src/components/custom/EmptyState/index.ts +2 -0
- package/src/components/custom/Metric/Metric.stories.tsx +133 -0
- package/src/components/custom/Metric/Metric.test.tsx +120 -0
- package/src/components/custom/Metric/Metric.tsx +92 -0
- package/src/components/custom/Metric/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
- package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
- package/src/components/custom/Sidebar/index.ts +18 -0
- package/src/components/custom/Table/Table.stories.tsx +481 -0
- package/src/components/custom/Table/Table.test.tsx +531 -0
- package/src/components/custom/Table/Table.tsx +696 -0
- package/src/components/custom/Table/index.ts +22 -0
- package/src/components/custom/Typography/Typography.stories.tsx +153 -0
- package/src/components/custom/Typography/Typography.test.tsx +107 -0
- package/src/components/custom/Typography/Typography.tsx +218 -0
- package/src/components/custom/Typography/index.ts +19 -0
- package/src/components/custom/index.ts +8 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
- package/src/components/custom/stepper/Stepper.test.tsx +230 -0
- package/src/components/custom/stepper/Stepper.tsx +250 -0
- package/src/components/custom/stepper/index.ts +10 -0
- package/src/components/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +154 -0
- package/src/components/ui/alert/Alert.test.tsx +202 -0
- package/src/components/ui/alert/Alert.tsx +170 -0
- package/src/components/ui/alert/index.ts +8 -0
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
- package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
- package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
- package/src/components/ui/autocomplete/index.ts +2 -0
- package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
- package/src/components/ui/avatar/Avatar.test.tsx +135 -0
- package/src/components/ui/avatar/Avatar.tsx +168 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/Badge.stories.tsx +100 -0
- package/src/components/ui/badge/Badge.test.tsx +86 -0
- package/src/components/ui/badge/Badge.tsx +105 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
- package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
- package/src/components/ui/breadcrumbs/index.ts +2 -0
- package/src/components/ui/button/Button.stories.tsx +216 -0
- package/src/components/ui/button/Button.test.tsx +132 -0
- package/src/components/ui/button/Button.tsx +242 -0
- package/src/components/ui/button/index.ts +10 -0
- package/src/components/ui/card/Card.stories.tsx +443 -0
- package/src/components/ui/card/Card.test.tsx +265 -0
- package/src/components/ui/card/Card.tsx +380 -0
- package/src/components/ui/card/index.ts +20 -0
- package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
- package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
- package/src/components/ui/checkbox/Checkbox.tsx +163 -0
- package/src/components/ui/checkbox/index.ts +2 -0
- package/src/components/ui/chip/Chip.stories.tsx +112 -0
- package/src/components/ui/chip/Chip.test.tsx +119 -0
- package/src/components/ui/chip/Chip.tsx +151 -0
- package/src/components/ui/chip/index.ts +2 -0
- package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
- package/src/components/ui/collapse/Collapse.test.tsx +298 -0
- package/src/components/ui/collapse/Collapse.tsx +275 -0
- package/src/components/ui/collapse/index.ts +18 -0
- package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
- package/src/components/ui/data-row/DataRow.test.tsx +78 -0
- package/src/components/ui/data-row/DataRow.tsx +29 -0
- package/src/components/ui/data-row/index.ts +2 -0
- package/src/components/ui/divider/Divider.stories.tsx +71 -0
- package/src/components/ui/divider/Divider.test.tsx +55 -0
- package/src/components/ui/divider/Divider.tsx +37 -0
- package/src/components/ui/divider/index.ts +2 -0
- package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
- package/src/components/ui/drawer/Drawer.test.tsx +142 -0
- package/src/components/ui/drawer/Drawer.tsx +228 -0
- package/src/components/ui/drawer/index.ts +9 -0
- package/src/components/ui/form-field/FormField.stories.tsx +260 -0
- package/src/components/ui/form-field/FormField.test.tsx +280 -0
- package/src/components/ui/form-field/FormField.tsx +308 -0
- package/src/components/ui/form-field/index.ts +2 -0
- package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
- package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
- package/src/components/ui/help-tip/HelpTip.tsx +229 -0
- package/src/components/ui/help-tip/index.ts +2 -0
- package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
- package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
- package/src/components/ui/icon-box/IconBox.tsx +57 -0
- package/src/components/ui/icon-box/index.ts +1 -0
- package/src/components/ui/index.ts +36 -0
- package/src/components/ui/input/Input.stories.tsx +192 -0
- package/src/components/ui/input/Input.test.tsx +86 -0
- package/src/components/ui/input/Input.tsx +273 -0
- package/src/components/ui/input/index.ts +2 -0
- package/src/components/ui/link/Link.stories.tsx +78 -0
- package/src/components/ui/link/Link.test.tsx +94 -0
- package/src/components/ui/link/Link.tsx +86 -0
- package/src/components/ui/link/index.ts +2 -0
- package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
- package/src/components/ui/list-item/ListItem.test.tsx +128 -0
- package/src/components/ui/list-item/ListItem.tsx +89 -0
- package/src/components/ui/list-item/index.ts +14 -0
- package/src/components/ui/menu/Menu.stories.tsx +180 -0
- package/src/components/ui/menu/Menu.test.tsx +353 -0
- package/src/components/ui/menu/Menu.tsx +222 -0
- package/src/components/ui/menu/index.ts +2 -0
- package/src/components/ui/modal/Modal.stories.tsx +350 -0
- package/src/components/ui/modal/Modal.test.tsx +159 -0
- package/src/components/ui/modal/Modal.tsx +254 -0
- package/src/components/ui/modal/index.ts +19 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +277 -0
- package/src/components/ui/popover/Popover.tsx +162 -0
- package/src/components/ui/popover/index.ts +2 -0
- package/src/components/ui/progress/Progress.stories.tsx +190 -0
- package/src/components/ui/progress/Progress.test.tsx +181 -0
- package/src/components/ui/progress/Progress.tsx +320 -0
- package/src/components/ui/progress/index.ts +9 -0
- package/src/components/ui/radio/Radio.stories.tsx +199 -0
- package/src/components/ui/radio/Radio.test.tsx +166 -0
- package/src/components/ui/radio/Radio.tsx +200 -0
- package/src/components/ui/radio/index.ts +2 -0
- package/src/components/ui/section/Section.stories.tsx +111 -0
- package/src/components/ui/section/Section.test.tsx +121 -0
- package/src/components/ui/section/Section.tsx +52 -0
- package/src/components/ui/section/index.ts +6 -0
- package/src/components/ui/select/Select.stories.tsx +286 -0
- package/src/components/ui/select/Select.test.tsx +180 -0
- package/src/components/ui/select/Select.tsx +236 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
- package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
- package/src/components/ui/skeleton/Skeleton.tsx +229 -0
- package/src/components/ui/skeleton/index.ts +16 -0
- package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
- package/src/components/ui/spinner/Spinner.test.tsx +67 -0
- package/src/components/ui/spinner/Spinner.tsx +72 -0
- package/src/components/ui/spinner/index.ts +2 -0
- package/src/components/ui/stack/Stack.stories.tsx +127 -0
- package/src/components/ui/stack/Stack.test.tsx +184 -0
- package/src/components/ui/stack/Stack.tsx +76 -0
- package/src/components/ui/stack/index.ts +2 -0
- package/src/components/ui/surface/Surface.stories.tsx +91 -0
- package/src/components/ui/surface/Surface.test.tsx +63 -0
- package/src/components/ui/surface/Surface.tsx +51 -0
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/switch/Switch.stories.tsx +111 -0
- package/src/components/ui/switch/Switch.test.tsx +112 -0
- package/src/components/ui/switch/Switch.tsx +103 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
- package/src/components/ui/tabs/Tabs.test.tsx +192 -0
- package/src/components/ui/tabs/Tabs.tsx +253 -0
- package/src/components/ui/tabs/index.ts +16 -0
- package/src/components/ui/toast/Toast.stories.tsx +255 -0
- package/src/components/ui/toast/Toast.test.tsx +207 -0
- package/src/components/ui/toast/Toast.tsx +327 -0
- package/src/components/ui/toast/index.ts +8 -0
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
- package/src/components/ui/toolbar-button/index.ts +7 -0
- package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
- package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
- package/src/components/ui/tooltip/Tooltip.tsx +143 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/test/setup.ts +19 -0
- package/src/theme/Colors.stories.tsx +258 -0
- package/src/theme/config.ts +193 -0
- package/src/theme/elevation.stories.tsx +346 -0
- package/src/theme/elevation.test.ts +27 -0
- package/src/theme/elevation.ts +567 -0
- package/src/theme/index.ts +39 -0
- package/src/theme/shadows.stories.tsx +523 -0
- package/src/theme/shadows.ts +439 -0
- package/src/theme/tokens/index.ts +2 -0
- package/src/theme/tokens/primitives.ts +354 -0
- package/src/theme/tokens/semantic.ts +381 -0
- package/src/types/jest-axe.d.ts +16 -0
- package/src/types/nativewind.d.ts +54 -0
- package/src/utils/cn.ts +14 -0
- package/src/utils/colors.ts +140 -0
- package/src/utils/form.ts +188 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/useTheme.ts +72 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { EmptyState } from './EmptyState'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof EmptyState> = {
|
|
6
|
+
title: 'Custom/EmptyState',
|
|
7
|
+
component: EmptyState,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
title: {
|
|
11
|
+
control: 'text',
|
|
12
|
+
description: 'Title text',
|
|
13
|
+
},
|
|
14
|
+
description: {
|
|
15
|
+
control: 'text',
|
|
16
|
+
description: 'Description text',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof EmptyState>
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
title: 'No results found',
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const WithDescription: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
title: 'No messages',
|
|
33
|
+
description: 'You don\'t have any messages yet. Start a conversation to see them here.',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Simple placeholder icon for story demos
|
|
38
|
+
function PlaceholderIcon({ size = 32, className }: { size?: number; className?: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<View
|
|
41
|
+
style={{
|
|
42
|
+
width: size,
|
|
43
|
+
height: size,
|
|
44
|
+
justifyContent: 'center',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<View
|
|
49
|
+
style={{
|
|
50
|
+
width: size * 0.8,
|
|
51
|
+
height: size * 0.8,
|
|
52
|
+
borderRadius: size * 0.4,
|
|
53
|
+
borderWidth: 2,
|
|
54
|
+
borderColor: 'var(--color-text-tertiary)',
|
|
55
|
+
justifyContent: 'center',
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<View style={{ width: size * 0.3, height: 2, backgroundColor: 'var(--color-text-tertiary)', borderRadius: 1 }} />
|
|
60
|
+
</View>
|
|
61
|
+
</View>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const WithIcon: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
icon: PlaceholderIcon,
|
|
68
|
+
title: 'Inbox is empty',
|
|
69
|
+
description: 'No new notifications at this time.',
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Simple action button for story demos
|
|
74
|
+
function ActionButton() {
|
|
75
|
+
return (
|
|
76
|
+
<View
|
|
77
|
+
className="bg-brand-primary px-4 py-2 rounded-md"
|
|
78
|
+
>
|
|
79
|
+
<Text className="text-white font-semibold text-sm">
|
|
80
|
+
Create New
|
|
81
|
+
</Text>
|
|
82
|
+
</View>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const WithAction: Story = {
|
|
87
|
+
args: {
|
|
88
|
+
icon: PlaceholderIcon,
|
|
89
|
+
title: 'No projects yet',
|
|
90
|
+
description: 'Get started by creating your first project.',
|
|
91
|
+
action: <ActionButton />,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const TitleOnly: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
title: 'Nothing here',
|
|
98
|
+
},
|
|
99
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { EmptyState } from './EmptyState'
|
|
5
|
+
|
|
6
|
+
function MockIcon({ size, className }: { size?: number; className?: string }) {
|
|
7
|
+
return <span data-testid="mock-icon" data-size={size} className={className}>Icon</span>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('EmptyState', () => {
|
|
11
|
+
it('renders with title', () => {
|
|
12
|
+
render(<EmptyState title="No messages" />)
|
|
13
|
+
expect(screen.getByText('No messages')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders with description', () => {
|
|
17
|
+
render(
|
|
18
|
+
<EmptyState
|
|
19
|
+
title="No messages"
|
|
20
|
+
description="You don't have any messages yet."
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
expect(screen.getByText("You don't have any messages yet.")).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders without description', () => {
|
|
27
|
+
render(<EmptyState title="No data" />)
|
|
28
|
+
expect(screen.getByText('No data')).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders with icon', () => {
|
|
32
|
+
render(<EmptyState title="No items" icon={MockIcon} />)
|
|
33
|
+
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('renders without icon', () => {
|
|
37
|
+
render(<EmptyState title="No items" />)
|
|
38
|
+
expect(screen.queryByTestId('mock-icon')).not.toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes size prop to icon', () => {
|
|
42
|
+
render(<EmptyState title="No items" icon={MockIcon} />)
|
|
43
|
+
expect(screen.getByTestId('mock-icon')).toHaveAttribute('data-size', '32')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders action element', () => {
|
|
47
|
+
const onPress = vi.fn()
|
|
48
|
+
render(
|
|
49
|
+
<EmptyState
|
|
50
|
+
title="No results"
|
|
51
|
+
action={<button onClick={onPress}>Create New</button>}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
const actionButton = screen.getByText('Create New')
|
|
55
|
+
expect(actionButton).toBeInTheDocument()
|
|
56
|
+
|
|
57
|
+
fireEvent.click(actionButton)
|
|
58
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders without action', () => {
|
|
62
|
+
render(<EmptyState title="No data" />)
|
|
63
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders all parts together', () => {
|
|
67
|
+
render(
|
|
68
|
+
<EmptyState
|
|
69
|
+
icon={MockIcon}
|
|
70
|
+
title="No messages"
|
|
71
|
+
description="Check back later"
|
|
72
|
+
action={<button>Refresh</button>}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
|
76
|
+
expect(screen.getByText('No messages')).toBeInTheDocument()
|
|
77
|
+
expect(screen.getByText('Check back later')).toBeInTheDocument()
|
|
78
|
+
expect(screen.getByText('Refresh')).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('accepts custom className', () => {
|
|
82
|
+
render(<EmptyState title="Test" className="custom-class" />)
|
|
83
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('accessibility', () => {
|
|
87
|
+
it('has no accessibility violations', async () => {
|
|
88
|
+
const { container } = render(
|
|
89
|
+
<EmptyState
|
|
90
|
+
icon={MockIcon}
|
|
91
|
+
title="No messages"
|
|
92
|
+
description="You don't have any messages yet."
|
|
93
|
+
action={<button>Compose</button>}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
const results = await axe(container)
|
|
97
|
+
expect(results).toHaveNoViolations()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('has no accessibility violations with minimal props', async () => {
|
|
101
|
+
const { container } = render(<EmptyState title="Empty" />)
|
|
102
|
+
const results = await axe(container)
|
|
103
|
+
expect(results).toHaveNoViolations()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface EmptyStateProps extends ViewProps {
|
|
6
|
+
/** Icon component to display */
|
|
7
|
+
icon?: React.ComponentType<{ size?: number; className?: string }>
|
|
8
|
+
/** Title text */
|
|
9
|
+
title: string
|
|
10
|
+
/** Description text */
|
|
11
|
+
description?: string
|
|
12
|
+
/** Action element (usually a button) */
|
|
13
|
+
action?: React.ReactNode
|
|
14
|
+
/** Additional className */
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Empty state component for when there's no data to display.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <EmptyState
|
|
23
|
+
* icon={InboxIcon}
|
|
24
|
+
* title="No messages"
|
|
25
|
+
* description="You don't have any messages yet."
|
|
26
|
+
* action={<Button>Compose</Button>}
|
|
27
|
+
* />
|
|
28
|
+
*/
|
|
29
|
+
export function EmptyState({
|
|
30
|
+
icon: Icon,
|
|
31
|
+
title,
|
|
32
|
+
description,
|
|
33
|
+
action,
|
|
34
|
+
className,
|
|
35
|
+
...props
|
|
36
|
+
}: EmptyStateProps) {
|
|
37
|
+
return (
|
|
38
|
+
<View
|
|
39
|
+
className={cn(
|
|
40
|
+
'items-center justify-center py-12 px-6',
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{Icon && (
|
|
46
|
+
<View className="mb-4 p-4 rounded-full bg-background-subtle">
|
|
47
|
+
<Icon size={32} className="text-text-tertiary" />
|
|
48
|
+
</View>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
<Text className="text-lg font-semibold text-text-primary text-center mb-2">
|
|
52
|
+
{title}
|
|
53
|
+
</Text>
|
|
54
|
+
|
|
55
|
+
{description && (
|
|
56
|
+
<Text className="text-sm text-text-secondary text-center max-w-xs mb-6">
|
|
57
|
+
{description}
|
|
58
|
+
</Text>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{action && (
|
|
62
|
+
<View className="mt-2">
|
|
63
|
+
{action}
|
|
64
|
+
</View>
|
|
65
|
+
)}
|
|
66
|
+
</View>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { Metric, MetricGroup } from './Metric'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Metric> = {
|
|
6
|
+
title: 'Custom/Metric',
|
|
7
|
+
component: Metric,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
value: {
|
|
11
|
+
control: 'text',
|
|
12
|
+
description: 'Formatted display value',
|
|
13
|
+
},
|
|
14
|
+
label: {
|
|
15
|
+
control: 'text',
|
|
16
|
+
description: 'Descriptive label below the value',
|
|
17
|
+
},
|
|
18
|
+
unit: {
|
|
19
|
+
control: 'text',
|
|
20
|
+
description: 'Optional unit suffix',
|
|
21
|
+
},
|
|
22
|
+
trend: {
|
|
23
|
+
control: 'select',
|
|
24
|
+
options: [undefined, 'up', 'down', 'neutral'],
|
|
25
|
+
description: 'Optional trend indicator arrow',
|
|
26
|
+
},
|
|
27
|
+
size: {
|
|
28
|
+
control: 'select',
|
|
29
|
+
options: ['sm', 'md', 'lg'],
|
|
30
|
+
description: 'Size variant',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default meta
|
|
36
|
+
type Story = StoryObj<typeof Metric>
|
|
37
|
+
|
|
38
|
+
export const Default: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
value: '42',
|
|
41
|
+
label: 'Total Reps',
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const WithUnit: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
value: '1.24',
|
|
48
|
+
label: 'Peak Velocity',
|
|
49
|
+
unit: 'm/s',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const WithTrendUp: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
value: '85',
|
|
56
|
+
label: 'Performance',
|
|
57
|
+
trend: 'up',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const WithTrendDown: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
value: '62',
|
|
64
|
+
label: 'Fatigue Index',
|
|
65
|
+
trend: 'down',
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const WithTrendNeutral: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
value: '70',
|
|
72
|
+
label: 'Consistency',
|
|
73
|
+
trend: 'neutral',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const FullMetric: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
value: '0.98',
|
|
80
|
+
label: 'Mean Velocity',
|
|
81
|
+
unit: 'm/s',
|
|
82
|
+
trend: 'up',
|
|
83
|
+
size: 'lg',
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const Small: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
value: '12',
|
|
90
|
+
label: 'Sets',
|
|
91
|
+
size: 'sm',
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const Large: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
value: '315',
|
|
98
|
+
label: 'Max Load',
|
|
99
|
+
unit: 'lbs',
|
|
100
|
+
size: 'lg',
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const AllSizes: Story = {
|
|
105
|
+
render: () => (
|
|
106
|
+
<View style={{ flexDirection: 'row', gap: 32, alignItems: 'flex-end' }}>
|
|
107
|
+
<Metric value="42" label="Reps" size="sm" />
|
|
108
|
+
<Metric value="42" label="Reps" size="md" />
|
|
109
|
+
<Metric value="42" label="Reps" size="lg" />
|
|
110
|
+
</View>
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const Group: StoryObj<typeof MetricGroup> = {
|
|
115
|
+
render: () => (
|
|
116
|
+
<MetricGroup>
|
|
117
|
+
<Metric value="12" label="Sets" />
|
|
118
|
+
<Metric value="42" label="Reps" />
|
|
119
|
+
<Metric value="1.24" label="Avg Velocity" unit="m/s" trend="up" />
|
|
120
|
+
</MetricGroup>
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const GroupSmall: StoryObj<typeof MetricGroup> = {
|
|
125
|
+
render: () => (
|
|
126
|
+
<MetricGroup>
|
|
127
|
+
<Metric value="8" label="Sets" size="sm" />
|
|
128
|
+
<Metric value="315" label="Load" unit="lbs" size="sm" />
|
|
129
|
+
<Metric value="0.82" label="Velocity" unit="m/s" size="sm" trend="down" />
|
|
130
|
+
<Metric value="92" label="Score" size="sm" trend="up" />
|
|
131
|
+
</MetricGroup>
|
|
132
|
+
),
|
|
133
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Metric, MetricGroup } from './Metric'
|
|
5
|
+
|
|
6
|
+
describe('Metric', () => {
|
|
7
|
+
it('renders value and label', () => {
|
|
8
|
+
render(<Metric value="42" label="Reps" />)
|
|
9
|
+
expect(screen.getByText('42')).toBeInTheDocument()
|
|
10
|
+
expect(screen.getByText('Reps')).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('renders unit when provided', () => {
|
|
14
|
+
render(<Metric value="1.2" label="Peak Velocity" unit="m/s" />)
|
|
15
|
+
expect(screen.getByText('m/s')).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not render unit when omitted', () => {
|
|
19
|
+
render(<Metric value="10" label="Sets" />)
|
|
20
|
+
expect(screen.queryByText('m/s')).not.toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders trend arrow when provided', () => {
|
|
24
|
+
render(<Metric value="85" label="Score" trend="up" />)
|
|
25
|
+
expect(screen.getByText('\u2191')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders down trend arrow', () => {
|
|
29
|
+
render(<Metric value="60" label="Score" trend="down" />)
|
|
30
|
+
expect(screen.getByText('\u2193')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders neutral trend arrow', () => {
|
|
34
|
+
render(<Metric value="70" label="Score" trend="neutral" />)
|
|
35
|
+
expect(screen.getByText('\u2192')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not render trend when omitted', () => {
|
|
39
|
+
render(<Metric value="50" label="Weight" />)
|
|
40
|
+
expect(screen.queryByText('\u2191')).not.toBeInTheDocument()
|
|
41
|
+
expect(screen.queryByText('\u2193')).not.toBeInTheDocument()
|
|
42
|
+
expect(screen.queryByText('\u2192')).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('defaults to md size', () => {
|
|
46
|
+
const { container } = render(<Metric value="10" label="Reps" />)
|
|
47
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts all size variants without error', () => {
|
|
51
|
+
const { rerender } = render(<Metric value="10" label="Reps" size="sm" />)
|
|
52
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
53
|
+
|
|
54
|
+
rerender(<Metric value="10" label="Reps" size="md" />)
|
|
55
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
56
|
+
|
|
57
|
+
rerender(<Metric value="10" label="Reps" size="lg" />)
|
|
58
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('accessibility', () => {
|
|
62
|
+
it('has no accessibility violations', async () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<Metric value="42" label="Reps" unit="reps" trend="up" />
|
|
65
|
+
)
|
|
66
|
+
const results = await axe(container)
|
|
67
|
+
expect(results).toHaveNoViolations()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('MetricGroup', () => {
|
|
73
|
+
it('renders all child metrics', () => {
|
|
74
|
+
render(
|
|
75
|
+
<MetricGroup>
|
|
76
|
+
<Metric value="10" label="Sets" />
|
|
77
|
+
<Metric value="42" label="Reps" />
|
|
78
|
+
<Metric value="1.2" label="Velocity" unit="m/s" />
|
|
79
|
+
</MetricGroup>
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
82
|
+
expect(screen.getByText('42')).toBeInTheDocument()
|
|
83
|
+
expect(screen.getByText('1.2')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('renders dividers between metrics', () => {
|
|
87
|
+
render(
|
|
88
|
+
<MetricGroup>
|
|
89
|
+
<Metric value="10" label="Sets" />
|
|
90
|
+
<Metric value="42" label="Reps" />
|
|
91
|
+
<Metric value="1.2" label="Velocity" />
|
|
92
|
+
</MetricGroup>
|
|
93
|
+
)
|
|
94
|
+
const dividers = screen.getAllByTestId('metric-divider')
|
|
95
|
+
expect(dividers).toHaveLength(2)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('renders no dividers for a single metric', () => {
|
|
99
|
+
render(
|
|
100
|
+
<MetricGroup>
|
|
101
|
+
<Metric value="10" label="Sets" />
|
|
102
|
+
</MetricGroup>
|
|
103
|
+
)
|
|
104
|
+
const dividers = screen.queryAllByTestId('metric-divider')
|
|
105
|
+
expect(dividers).toHaveLength(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('accessibility', () => {
|
|
109
|
+
it('has no accessibility violations', async () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<MetricGroup>
|
|
112
|
+
<Metric value="10" label="Sets" />
|
|
113
|
+
<Metric value="42" label="Reps" />
|
|
114
|
+
</MetricGroup>
|
|
115
|
+
)
|
|
116
|
+
const results = await axe(container)
|
|
117
|
+
expect(results).toHaveNoViolations()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export type MetricTrend = 'up' | 'down' | 'neutral'
|
|
6
|
+
|
|
7
|
+
export interface MetricProps extends ViewProps {
|
|
8
|
+
value: string
|
|
9
|
+
label: string
|
|
10
|
+
unit?: string
|
|
11
|
+
trend?: MetricTrend
|
|
12
|
+
size?: 'sm' | 'md' | 'lg'
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeConfig = {
|
|
17
|
+
sm: { value: 'text-lg font-bold', label: 'text-xs', unit: 'text-xs' },
|
|
18
|
+
md: { value: 'text-2xl font-bold', label: 'text-xs', unit: 'text-sm' },
|
|
19
|
+
lg: { value: 'text-4xl font-bold', label: 'text-sm', unit: 'text-base' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trendColors: Record<MetricTrend, string> = {
|
|
23
|
+
up: 'text-result-improve',
|
|
24
|
+
down: 'text-result-degrade',
|
|
25
|
+
neutral: 'text-result-inconclusive',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trendArrows: Record<MetricTrend, string> = {
|
|
29
|
+
up: '\u2191',
|
|
30
|
+
down: '\u2193',
|
|
31
|
+
neutral: '\u2192',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Metric({
|
|
35
|
+
value,
|
|
36
|
+
label,
|
|
37
|
+
unit,
|
|
38
|
+
trend,
|
|
39
|
+
size = 'md',
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
}: MetricProps) {
|
|
43
|
+
const styles = sizeConfig[size]
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<View className={cn('items-center', className)} {...props}>
|
|
47
|
+
<View className="flex-row items-baseline gap-1">
|
|
48
|
+
<Text className={cn(styles.value, 'text-text-primary')}>{value}</Text>
|
|
49
|
+
{unit && (
|
|
50
|
+
<Text className={cn(styles.unit, 'text-text-tertiary')}>{unit}</Text>
|
|
51
|
+
)}
|
|
52
|
+
{trend && (
|
|
53
|
+
<Text className={cn(styles.unit, trendColors[trend])}>
|
|
54
|
+
{trendArrows[trend]}
|
|
55
|
+
</Text>
|
|
56
|
+
)}
|
|
57
|
+
</View>
|
|
58
|
+
<Text className={cn(styles.label, 'text-text-secondary mt-1')}>
|
|
59
|
+
{label}
|
|
60
|
+
</Text>
|
|
61
|
+
</View>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MetricGroupProps extends ViewProps {
|
|
66
|
+
className?: string
|
|
67
|
+
children: React.ReactNode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function MetricGroup({
|
|
71
|
+
className,
|
|
72
|
+
children,
|
|
73
|
+
...props
|
|
74
|
+
}: MetricGroupProps) {
|
|
75
|
+
const items = React.Children.toArray(children)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<View className={cn('flex-row items-center', className)} {...props}>
|
|
79
|
+
{items.map((child, i) => (
|
|
80
|
+
<React.Fragment key={i}>
|
|
81
|
+
<View className="flex-1 items-center">{child}</View>
|
|
82
|
+
{i < items.length - 1 && (
|
|
83
|
+
<View
|
|
84
|
+
className="w-px h-8 bg-divider mx-2"
|
|
85
|
+
testID="metric-divider"
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
</React.Fragment>
|
|
89
|
+
))}
|
|
90
|
+
</View>
|
|
91
|
+
)
|
|
92
|
+
}
|