@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,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text } from 'react-native'
|
|
5
|
+
import { Section, SectionHeader, SectionContent } from './Section'
|
|
6
|
+
|
|
7
|
+
describe('Section', () => {
|
|
8
|
+
it('renders children correctly', () => {
|
|
9
|
+
render(
|
|
10
|
+
<Section>
|
|
11
|
+
<Text>Section content</Text>
|
|
12
|
+
</Section>
|
|
13
|
+
)
|
|
14
|
+
expect(screen.getByText('Section content')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('applies custom className', () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<Section className="mt-4" testID="section">
|
|
20
|
+
<Text>Content</Text>
|
|
21
|
+
</Section>
|
|
22
|
+
)
|
|
23
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('SectionHeader', () => {
|
|
28
|
+
it('renders title', () => {
|
|
29
|
+
render(<SectionHeader title="My Section" />)
|
|
30
|
+
expect(screen.getByText('My Section')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders subtitle when provided', () => {
|
|
34
|
+
render(<SectionHeader title="Title" subtitle="Subtitle text" />)
|
|
35
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
36
|
+
expect(screen.getByText('Subtitle text')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not render subtitle when not provided', () => {
|
|
40
|
+
render(<SectionHeader title="Title" />)
|
|
41
|
+
expect(screen.queryByText('Subtitle text')).not.toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders trailing action when provided', () => {
|
|
45
|
+
render(
|
|
46
|
+
<SectionHeader
|
|
47
|
+
title="Title"
|
|
48
|
+
trailing={<Text>View All</Text>}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
expect(screen.getByText('View All')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('does not render trailing when not provided', () => {
|
|
55
|
+
const { container } = render(<SectionHeader title="Title" />)
|
|
56
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('applies custom className', () => {
|
|
60
|
+
render(<SectionHeader title="Title" className="px-4" />)
|
|
61
|
+
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('SectionContent', () => {
|
|
66
|
+
it('renders children correctly', () => {
|
|
67
|
+
render(
|
|
68
|
+
<SectionContent>
|
|
69
|
+
<Text>Inner content</Text>
|
|
70
|
+
</SectionContent>
|
|
71
|
+
)
|
|
72
|
+
expect(screen.getByText('Inner content')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('applies custom className', () => {
|
|
76
|
+
render(
|
|
77
|
+
<SectionContent className="p-4">
|
|
78
|
+
<Text>Styled content</Text>
|
|
79
|
+
</SectionContent>
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByText('Styled content')).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('Section compound component', () => {
|
|
86
|
+
it('composes all subcomponents together', () => {
|
|
87
|
+
render(
|
|
88
|
+
<Section>
|
|
89
|
+
<SectionHeader
|
|
90
|
+
title="Workout Stats"
|
|
91
|
+
subtitle="Last 7 days"
|
|
92
|
+
trailing={<Text>See All</Text>}
|
|
93
|
+
/>
|
|
94
|
+
<SectionContent>
|
|
95
|
+
<Text>Stats go here</Text>
|
|
96
|
+
</SectionContent>
|
|
97
|
+
</Section>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('Workout Stats')).toBeInTheDocument()
|
|
101
|
+
expect(screen.getByText('Last 7 days')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByText('See All')).toBeInTheDocument()
|
|
103
|
+
expect(screen.getByText('Stats go here')).toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('accessibility', () => {
|
|
107
|
+
it('has no accessibility violations', async () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<Section>
|
|
110
|
+
<SectionHeader title="Accessible Section" subtitle="With subtitle" />
|
|
111
|
+
<SectionContent>
|
|
112
|
+
<Text>Content</Text>
|
|
113
|
+
</SectionContent>
|
|
114
|
+
</Section>
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const results = await axe(container)
|
|
118
|
+
expect(results).toHaveNoViolations()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface SectionProps extends ViewProps {
|
|
6
|
+
className?: string
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Section({ className, children, ...props }: SectionProps) {
|
|
11
|
+
return (
|
|
12
|
+
<View className={cn('mb-6', className)} {...props}>
|
|
13
|
+
{children}
|
|
14
|
+
</View>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SectionHeaderProps {
|
|
19
|
+
title: string
|
|
20
|
+
subtitle?: string
|
|
21
|
+
trailing?: React.ReactNode
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SectionHeader({ title, subtitle, trailing, className }: SectionHeaderProps) {
|
|
26
|
+
return (
|
|
27
|
+
<View className={cn('flex-row items-center justify-between mb-3 px-1', className)}>
|
|
28
|
+
<View className="flex-1">
|
|
29
|
+
<Text className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
|
30
|
+
{title}
|
|
31
|
+
</Text>
|
|
32
|
+
{subtitle && (
|
|
33
|
+
<Text className="text-xs text-text-tertiary mt-0.5">{subtitle}</Text>
|
|
34
|
+
)}
|
|
35
|
+
</View>
|
|
36
|
+
{trailing && <View>{trailing}</View>}
|
|
37
|
+
</View>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SectionContentProps extends ViewProps {
|
|
42
|
+
className?: string
|
|
43
|
+
children: React.ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SectionContent({ className, children, ...props }: SectionContentProps) {
|
|
47
|
+
return (
|
|
48
|
+
<View className={cn(className)} {...props}>
|
|
49
|
+
{children}
|
|
50
|
+
</View>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { View, Text } from 'react-native'
|
|
4
|
+
import { Select, type SelectOption } from './Select'
|
|
5
|
+
|
|
6
|
+
const basicOptions: SelectOption[] = [
|
|
7
|
+
{ value: 'apple', label: 'Apple' },
|
|
8
|
+
{ value: 'banana', label: 'Banana' },
|
|
9
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
10
|
+
{ value: 'dragonfruit', label: 'Dragon Fruit' },
|
|
11
|
+
{ value: 'elderberry', label: 'Elderberry' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof Select> = {
|
|
15
|
+
title: 'Components/Select',
|
|
16
|
+
component: Select,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
argTypes: {
|
|
19
|
+
placeholder: {
|
|
20
|
+
control: 'text',
|
|
21
|
+
description: 'Placeholder text when no value is selected',
|
|
22
|
+
},
|
|
23
|
+
isDisabled: {
|
|
24
|
+
control: 'boolean',
|
|
25
|
+
description: 'Whether the select is disabled',
|
|
26
|
+
},
|
|
27
|
+
isInvalid: {
|
|
28
|
+
control: 'boolean',
|
|
29
|
+
description: 'Whether the select has an error state',
|
|
30
|
+
},
|
|
31
|
+
isMulti: {
|
|
32
|
+
control: 'boolean',
|
|
33
|
+
description: 'Enable multi-select mode',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
type Story = StoryObj<typeof Select>
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
render: function Render() {
|
|
43
|
+
const [value, setValue] = useState<string | null>(null)
|
|
44
|
+
return (
|
|
45
|
+
<View style={{ width: 300 }}>
|
|
46
|
+
<Select
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={setValue}
|
|
49
|
+
options={basicOptions}
|
|
50
|
+
placeholder="Select a fruit..."
|
|
51
|
+
/>
|
|
52
|
+
</View>
|
|
53
|
+
)
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const WithPlaceholder: Story = {
|
|
58
|
+
render: function Render() {
|
|
59
|
+
const [value, setValue] = useState<string | null>(null)
|
|
60
|
+
return (
|
|
61
|
+
<View style={{ width: 300 }}>
|
|
62
|
+
<Select
|
|
63
|
+
value={value}
|
|
64
|
+
onChange={setValue}
|
|
65
|
+
options={basicOptions}
|
|
66
|
+
placeholder="Choose your favorite fruit..."
|
|
67
|
+
/>
|
|
68
|
+
</View>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const WithPreselectedValue: Story = {
|
|
74
|
+
render: function Render() {
|
|
75
|
+
const [value, setValue] = useState<string | null>('cherry')
|
|
76
|
+
return (
|
|
77
|
+
<View style={{ width: 300 }}>
|
|
78
|
+
<Select
|
|
79
|
+
value={value}
|
|
80
|
+
onChange={setValue}
|
|
81
|
+
options={basicOptions}
|
|
82
|
+
placeholder="Select a fruit..."
|
|
83
|
+
/>
|
|
84
|
+
</View>
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const Disabled: Story = {
|
|
90
|
+
render: () => (
|
|
91
|
+
<View style={{ gap: 12, width: 300 }}>
|
|
92
|
+
<View style={{ gap: 4 }}>
|
|
93
|
+
<Text className="text-sm text-text-secondary">Disabled with no value</Text>
|
|
94
|
+
<Select
|
|
95
|
+
value={null}
|
|
96
|
+
onChange={() => {}}
|
|
97
|
+
options={basicOptions}
|
|
98
|
+
isDisabled
|
|
99
|
+
placeholder="Select a fruit..."
|
|
100
|
+
/>
|
|
101
|
+
</View>
|
|
102
|
+
<View style={{ gap: 4 }}>
|
|
103
|
+
<Text className="text-sm text-text-secondary">Disabled with value</Text>
|
|
104
|
+
<Select
|
|
105
|
+
value="banana"
|
|
106
|
+
onChange={() => {}}
|
|
107
|
+
options={basicOptions}
|
|
108
|
+
isDisabled
|
|
109
|
+
/>
|
|
110
|
+
</View>
|
|
111
|
+
</View>
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const Invalid: Story = {
|
|
116
|
+
render: function Render() {
|
|
117
|
+
const [value, setValue] = useState<string | null>(null)
|
|
118
|
+
return (
|
|
119
|
+
<View style={{ width: 300, gap: 4 }}>
|
|
120
|
+
<Select
|
|
121
|
+
value={value}
|
|
122
|
+
onChange={setValue}
|
|
123
|
+
options={basicOptions}
|
|
124
|
+
isInvalid
|
|
125
|
+
placeholder="Select a fruit..."
|
|
126
|
+
/>
|
|
127
|
+
<Text className="text-sm text-status-error">This field is required</Text>
|
|
128
|
+
</View>
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const MultiSelect: Story = {
|
|
134
|
+
render: function Render() {
|
|
135
|
+
const [values, setValues] = useState<string[]>([])
|
|
136
|
+
return (
|
|
137
|
+
<View style={{ width: 300, gap: 8 }}>
|
|
138
|
+
<Select
|
|
139
|
+
isMulti
|
|
140
|
+
values={values}
|
|
141
|
+
onChangeMulti={setValues}
|
|
142
|
+
options={basicOptions}
|
|
143
|
+
placeholder="Select fruits..."
|
|
144
|
+
/>
|
|
145
|
+
<Text className="text-sm text-text-secondary">
|
|
146
|
+
Selected: {values.length > 0 ? values.join(', ') : 'none'}
|
|
147
|
+
</Text>
|
|
148
|
+
</View>
|
|
149
|
+
)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const MultiSelectWithPreselected: Story = {
|
|
154
|
+
render: function Render() {
|
|
155
|
+
const [values, setValues] = useState<string[]>(['apple', 'cherry'])
|
|
156
|
+
return (
|
|
157
|
+
<View style={{ width: 300, gap: 8 }}>
|
|
158
|
+
<Select
|
|
159
|
+
isMulti
|
|
160
|
+
values={values}
|
|
161
|
+
onChangeMulti={setValues}
|
|
162
|
+
options={basicOptions}
|
|
163
|
+
placeholder="Select fruits..."
|
|
164
|
+
/>
|
|
165
|
+
<Text className="text-sm text-text-secondary">
|
|
166
|
+
Selected: {values.length > 0 ? values.join(', ') : 'none'}
|
|
167
|
+
</Text>
|
|
168
|
+
</View>
|
|
169
|
+
)
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const ManyOptions: Story = {
|
|
174
|
+
render: function Render() {
|
|
175
|
+
const manyOptions: SelectOption[] = Array.from({ length: 50 }, (_, i) => ({
|
|
176
|
+
value: `option-${i + 1}`,
|
|
177
|
+
label: `Option ${i + 1}`,
|
|
178
|
+
}))
|
|
179
|
+
const [value, setValue] = useState<string | null>(null)
|
|
180
|
+
return (
|
|
181
|
+
<View style={{ width: 300 }}>
|
|
182
|
+
<Select
|
|
183
|
+
value={value}
|
|
184
|
+
onChange={setValue}
|
|
185
|
+
options={manyOptions}
|
|
186
|
+
placeholder="Select from many options..."
|
|
187
|
+
/>
|
|
188
|
+
</View>
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const WithDisabledOptions: Story = {
|
|
194
|
+
render: function Render() {
|
|
195
|
+
const optionsWithDisabled: SelectOption[] = [
|
|
196
|
+
{ value: 'apple', label: 'Apple' },
|
|
197
|
+
{ value: 'banana', label: 'Banana', isDisabled: true },
|
|
198
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
199
|
+
{ value: 'dragonfruit', label: 'Dragon Fruit', isDisabled: true },
|
|
200
|
+
{ value: 'elderberry', label: 'Elderberry' },
|
|
201
|
+
]
|
|
202
|
+
const [value, setValue] = useState<string | null>(null)
|
|
203
|
+
return (
|
|
204
|
+
<View style={{ width: 300 }}>
|
|
205
|
+
<Select
|
|
206
|
+
value={value}
|
|
207
|
+
onChange={setValue}
|
|
208
|
+
options={optionsWithDisabled}
|
|
209
|
+
placeholder="Some options are disabled..."
|
|
210
|
+
/>
|
|
211
|
+
</View>
|
|
212
|
+
)
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const Controlled: Story = {
|
|
217
|
+
render: function Render() {
|
|
218
|
+
const [value, setValue] = useState<string | null>('apple')
|
|
219
|
+
return (
|
|
220
|
+
<View style={{ width: 300, gap: 12 }}>
|
|
221
|
+
<Select
|
|
222
|
+
value={value}
|
|
223
|
+
onChange={setValue}
|
|
224
|
+
options={basicOptions}
|
|
225
|
+
placeholder="Select a fruit..."
|
|
226
|
+
/>
|
|
227
|
+
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
|
228
|
+
{basicOptions.map((option) => (
|
|
229
|
+
<View key={option.value} style={{ marginBottom: 4 }}>
|
|
230
|
+
<Text
|
|
231
|
+
className="text-sm text-brand-primary underline"
|
|
232
|
+
onPress={() => setValue(option.value)}
|
|
233
|
+
>
|
|
234
|
+
Set {option.label}
|
|
235
|
+
</Text>
|
|
236
|
+
</View>
|
|
237
|
+
))}
|
|
238
|
+
<Text
|
|
239
|
+
className="text-sm text-status-error underline"
|
|
240
|
+
onPress={() => setValue(null)}
|
|
241
|
+
>
|
|
242
|
+
Clear
|
|
243
|
+
</Text>
|
|
244
|
+
</View>
|
|
245
|
+
<Text className="text-sm text-text-secondary">
|
|
246
|
+
Current value: {value ?? 'null'}
|
|
247
|
+
</Text>
|
|
248
|
+
</View>
|
|
249
|
+
)
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const InFormLayout: Story = {
|
|
254
|
+
render: function Render() {
|
|
255
|
+
const [fruit, setFruit] = useState<string | null>(null)
|
|
256
|
+
const [color, setColor] = useState<string | null>(null)
|
|
257
|
+
const colorOptions: SelectOption[] = [
|
|
258
|
+
{ value: 'red', label: 'Red' },
|
|
259
|
+
{ value: 'green', label: 'Green' },
|
|
260
|
+
{ value: 'blue', label: 'Blue' },
|
|
261
|
+
{ value: 'yellow', label: 'Yellow' },
|
|
262
|
+
]
|
|
263
|
+
return (
|
|
264
|
+
<View style={{ width: 300, gap: 16 }}>
|
|
265
|
+
<View style={{ gap: 4 }}>
|
|
266
|
+
<Text className="text-sm font-medium text-text-primary">Favorite Fruit</Text>
|
|
267
|
+
<Select
|
|
268
|
+
value={fruit}
|
|
269
|
+
onChange={setFruit}
|
|
270
|
+
options={basicOptions}
|
|
271
|
+
placeholder="Select a fruit..."
|
|
272
|
+
/>
|
|
273
|
+
</View>
|
|
274
|
+
<View style={{ gap: 4 }}>
|
|
275
|
+
<Text className="text-sm font-medium text-text-primary">Favorite Color</Text>
|
|
276
|
+
<Select
|
|
277
|
+
value={color}
|
|
278
|
+
onChange={setColor}
|
|
279
|
+
options={colorOptions}
|
|
280
|
+
placeholder="Select a color..."
|
|
281
|
+
/>
|
|
282
|
+
</View>
|
|
283
|
+
</View>
|
|
284
|
+
)
|
|
285
|
+
},
|
|
286
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
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 { Select } from './Select'
|
|
5
|
+
|
|
6
|
+
const defaultOptions = [
|
|
7
|
+
{ value: '1', label: 'Option 1' },
|
|
8
|
+
{ value: '2', label: 'Option 2' },
|
|
9
|
+
{ value: '3', label: 'Option 3' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const optionsWithDisabled = [
|
|
13
|
+
{ value: '1', label: 'Option 1' },
|
|
14
|
+
{ value: '2', label: 'Option 2', isDisabled: true },
|
|
15
|
+
{ value: '3', label: 'Option 3' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
describe('Select', () => {
|
|
19
|
+
it('renders with placeholder', () => {
|
|
20
|
+
render(<Select options={defaultOptions} placeholder="Pick one" />)
|
|
21
|
+
expect(screen.getByText('Pick one')).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders default placeholder when none provided', () => {
|
|
25
|
+
render(<Select options={defaultOptions} />)
|
|
26
|
+
expect(screen.getByText('Select...')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('displays selected value label', () => {
|
|
30
|
+
render(<Select options={defaultOptions} value="2" />)
|
|
31
|
+
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('opens dropdown when trigger is pressed', () => {
|
|
35
|
+
render(<Select options={defaultOptions} />)
|
|
36
|
+
const trigger = screen.getByRole('combobox')
|
|
37
|
+
fireEvent.click(trigger)
|
|
38
|
+
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
|
40
|
+
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('calls onChange when an option is selected', () => {
|
|
44
|
+
const onChange = vi.fn()
|
|
45
|
+
render(<Select options={defaultOptions} onChange={onChange} />)
|
|
46
|
+
|
|
47
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
48
|
+
fireEvent.click(screen.getByText('Option 2'))
|
|
49
|
+
expect(onChange).toHaveBeenCalledWith('2')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('closes dropdown after single-select selection', () => {
|
|
53
|
+
const onChange = vi.fn()
|
|
54
|
+
render(<Select options={defaultOptions} onChange={onChange} />)
|
|
55
|
+
|
|
56
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
57
|
+
fireEvent.click(screen.getByText('Option 1'))
|
|
58
|
+
|
|
59
|
+
// After selection, dropdown should close (options no longer visible beyond the selected display)
|
|
60
|
+
expect(onChange).toHaveBeenCalledWith('1')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not open when disabled', () => {
|
|
64
|
+
render(<Select options={defaultOptions} isDisabled />)
|
|
65
|
+
const trigger = screen.getByRole('combobox')
|
|
66
|
+
fireEvent.click(trigger)
|
|
67
|
+
// Options should not appear
|
|
68
|
+
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows clear button when value is selected', () => {
|
|
72
|
+
const onChange = vi.fn()
|
|
73
|
+
render(<Select options={defaultOptions} value="1" onChange={onChange} />)
|
|
74
|
+
// The clear button renders a "x" character
|
|
75
|
+
const clearButtons = screen.getAllByText('\u00d7')
|
|
76
|
+
expect(clearButtons.length).toBeGreaterThan(0)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('multi-select', () => {
|
|
80
|
+
it('renders with multi-select mode', () => {
|
|
81
|
+
render(<Select options={defaultOptions} isMulti values={[]} />)
|
|
82
|
+
expect(screen.getByText('Select...')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('displays count when multiple values selected', () => {
|
|
86
|
+
render(<Select options={defaultOptions} isMulti values={['1', '2']} />)
|
|
87
|
+
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('displays single label when one value selected in multi mode', () => {
|
|
91
|
+
render(<Select options={defaultOptions} isMulti values={['1']} />)
|
|
92
|
+
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('calls onChangeMulti when toggling options', () => {
|
|
96
|
+
const onChangeMulti = vi.fn()
|
|
97
|
+
render(
|
|
98
|
+
<Select
|
|
99
|
+
options={defaultOptions}
|
|
100
|
+
isMulti
|
|
101
|
+
values={[]}
|
|
102
|
+
onChangeMulti={onChangeMulti}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
107
|
+
fireEvent.click(screen.getByText('Option 1'))
|
|
108
|
+
expect(onChangeMulti).toHaveBeenCalledWith(['1'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('removes value when already selected in multi mode', () => {
|
|
112
|
+
const onChangeMulti = vi.fn()
|
|
113
|
+
render(
|
|
114
|
+
<Select
|
|
115
|
+
options={defaultOptions}
|
|
116
|
+
isMulti
|
|
117
|
+
values={['1', '2']}
|
|
118
|
+
onChangeMulti={onChangeMulti}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
123
|
+
fireEvent.click(screen.getByText('Option 1'))
|
|
124
|
+
expect(onChangeMulti).toHaveBeenCalledWith(['2'])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('disabled options', () => {
|
|
129
|
+
it('renders disabled options with reduced opacity', () => {
|
|
130
|
+
render(<Select options={optionsWithDisabled} />)
|
|
131
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
132
|
+
// All option labels should render
|
|
133
|
+
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('invalid state', () => {
|
|
138
|
+
it('renders with error styling when isInvalid', () => {
|
|
139
|
+
render(<Select options={defaultOptions} isInvalid />)
|
|
140
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('accessibility', () => {
|
|
145
|
+
it('has no accessibility violations', async () => {
|
|
146
|
+
const { container } = render(
|
|
147
|
+
<Select options={defaultOptions} value="1" />
|
|
148
|
+
)
|
|
149
|
+
const results = await axe(container, {
|
|
150
|
+
rules: {
|
|
151
|
+
'aria-input-field-name': { enabled: false },
|
|
152
|
+
'aria-required-attr': { enabled: false },
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
expect(results).toHaveNoViolations()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('has combobox role on trigger', () => {
|
|
159
|
+
render(<Select options={defaultOptions} />)
|
|
160
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('communicates expanded state', () => {
|
|
164
|
+
render(<Select options={defaultOptions} />)
|
|
165
|
+
const trigger = screen.getByRole('combobox')
|
|
166
|
+
// react-native-web does not map accessibilityState.expanded to aria-expanded
|
|
167
|
+
expect(trigger).toBeInTheDocument()
|
|
168
|
+
|
|
169
|
+
fireEvent.click(trigger)
|
|
170
|
+
// Verify dropdown opened by checking for options
|
|
171
|
+
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('communicates disabled state', () => {
|
|
175
|
+
render(<Select options={defaultOptions} isDisabled />)
|
|
176
|
+
const trigger = screen.getByRole('combobox')
|
|
177
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|