@titan-design/react-ui 0.1.0 → 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.
- package/README.md +47 -1
- package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
- package/dist/chunk-UXVRPOME.mjs.map +1 -0
- package/dist/index.d.mts +101 -12
- package/dist/index.d.ts +101 -12
- package/dist/index.js +1267 -468
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +734 -112
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +86 -1
- package/dist/theme/index.d.ts +86 -1
- package/dist/theme/index.js +166 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +22 -6
- 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/Workout/RestTimer.stories.tsx +71 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
- package/src/components/custom/Workout/RestTimer.tsx +172 -0
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
- package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
- package/src/components/custom/Workout/index.ts +2 -0
- package/src/components/custom/index.ts +9 -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 +152 -0
- package/src/components/ui/avatar/Avatar.tsx +176 -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 +103 -0
- package/src/components/ui/badge/Badge.tsx +119 -0
- package/src/components/ui/badge/index.ts +3 -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 +297 -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 +294 -0
- package/src/components/ui/card/Card.tsx +401 -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 +38 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
- package/src/components/ui/indicator/Indicator.test.tsx +55 -0
- package/src/components/ui/indicator/Indicator.tsx +73 -0
- package/src/components/ui/indicator/index.ts +2 -0
- package/src/components/ui/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/pill/Pill.stories.tsx +71 -0
- package/src/components/ui/pill/Pill.test.tsx +69 -0
- package/src/components/ui/pill/Pill.tsx +104 -0
- package/src/components/ui/pill/index.ts +2 -0
- package/src/components/ui/popover/Popover.stories.tsx +190 -0
- package/src/components/ui/popover/Popover.test.tsx +419 -0
- package/src/components/ui/popover/Popover.tsx +237 -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 +210 -0
- package/src/components/ui/progress/Progress.tsx +339 -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 +200 -0
- package/src/components/ui/select/Select.tsx +243 -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 +9 -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 +262 -0
- package/src/components/ui/tooltip/Tooltip.tsx +234 -0
- package/src/components/ui/tooltip/index.ts +2 -0
- package/src/index.ts +10 -0
- package/src/stories/PresetShowcase.stories.tsx +462 -0
- package/src/stories/ThemePresets.stories.tsx +85 -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/global.css +129 -1
- package/src/theme/index.ts +40 -0
- package/src/theme/presets/apply.ts +97 -0
- package/src/theme/presets/audiobook.ts +93 -0
- package/src/theme/presets/default.ts +6 -0
- package/src/theme/presets/index.ts +4 -0
- package/src/theme/presets/presets.test.ts +51 -0
- package/src/theme/presets/types.ts +76 -0
- package/src/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/avatar-color.test.ts +30 -0
- package/src/utils/avatar-color.ts +20 -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
- package/src/web-jsx/jsx-dev-runtime.ts +28 -0
- package/src/web-jsx/jsx-runtime.ts +41 -0
- package/tailwind.config.js +53 -3
- package/dist/chunk-MGW37MPO.mjs.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
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 { Link } from './Link'
|
|
5
|
+
|
|
6
|
+
describe('Link', () => {
|
|
7
|
+
it('renders children correctly', () => {
|
|
8
|
+
render(<Link>Click here</Link>)
|
|
9
|
+
expect(screen.getByText('Click here')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('has link accessibility role', () => {
|
|
13
|
+
render(<Link>Link</Link>)
|
|
14
|
+
expect(screen.getByRole('link')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('handles press events', () => {
|
|
18
|
+
const onPress = vi.fn()
|
|
19
|
+
render(<Link onPress={onPress}>Press me</Link>)
|
|
20
|
+
fireEvent.click(screen.getByRole('link'))
|
|
21
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not fire onPress when disabled', () => {
|
|
25
|
+
const onPress = vi.fn()
|
|
26
|
+
render(
|
|
27
|
+
<Link onPress={onPress} isDisabled>
|
|
28
|
+
Disabled
|
|
29
|
+
</Link>
|
|
30
|
+
)
|
|
31
|
+
fireEvent.click(screen.getByRole('link'))
|
|
32
|
+
expect(onPress).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('appends external indicator when isExternal', () => {
|
|
36
|
+
render(<Link isExternal>External</Link>)
|
|
37
|
+
expect(screen.getByText(/External/)).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText(/\u2197/)).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('provides accessibility hint for external links', () => {
|
|
42
|
+
render(<Link isExternal>External</Link>)
|
|
43
|
+
// react-native-web does not map accessibilityHint to aria-description
|
|
44
|
+
expect(screen.getByRole('link')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not have external hint for internal links', () => {
|
|
48
|
+
render(<Link>Internal</Link>)
|
|
49
|
+
expect(screen.getByRole('link')).not.toHaveAttribute('aria-description')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders with all color options', () => {
|
|
53
|
+
const colors = ['default', 'primary', 'secondary', 'inherit'] as const
|
|
54
|
+
colors.forEach((color) => {
|
|
55
|
+
const { unmount } = render(<Link color={color}>Test</Link>)
|
|
56
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
57
|
+
unmount()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders with all underline options', () => {
|
|
62
|
+
const underlines = ['always', 'hover', 'none'] as const
|
|
63
|
+
underlines.forEach((underline) => {
|
|
64
|
+
const { unmount } = render(<Link underline={underline}>Test</Link>)
|
|
65
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
66
|
+
unmount()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('applies custom className', () => {
|
|
71
|
+
render(<Link className="extra">Test</Link>)
|
|
72
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('disables the pressable when isDisabled', () => {
|
|
76
|
+
render(<Link isDisabled>Disabled link</Link>)
|
|
77
|
+
// react-native-web renders aria-disabled on a div, toBeDisabled() only works on form elements
|
|
78
|
+
expect(screen.getByRole('link')).toHaveAttribute('aria-disabled', 'true')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('accessibility', () => {
|
|
82
|
+
it('has no accessibility violations', async () => {
|
|
83
|
+
const { container } = render(<Link>Visit site</Link>)
|
|
84
|
+
const results = await axe(container)
|
|
85
|
+
expect(results).toHaveNoViolations()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('has no accessibility violations for external link', async () => {
|
|
89
|
+
const { container } = render(<Link isExternal>External site</Link>)
|
|
90
|
+
const results = await axe(container)
|
|
91
|
+
expect(results).toHaveNoViolations()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Text, Pressable, type TextProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export type LinkUnderline = 'always' | 'hover' | 'none'
|
|
6
|
+
export type LinkColor = 'default' | 'primary' | 'secondary' | 'inherit'
|
|
7
|
+
|
|
8
|
+
export interface LinkProps extends Omit<TextProps, 'onPress'> {
|
|
9
|
+
/** URL to navigate to (for accessibility) */
|
|
10
|
+
href?: string
|
|
11
|
+
/** Underline behavior */
|
|
12
|
+
underline?: LinkUnderline
|
|
13
|
+
/** Link color */
|
|
14
|
+
color?: LinkColor
|
|
15
|
+
/** Whether the link opens in a new tab/window */
|
|
16
|
+
isExternal?: boolean
|
|
17
|
+
/** Press handler */
|
|
18
|
+
onPress?: () => void
|
|
19
|
+
/** Whether the link is disabled */
|
|
20
|
+
isDisabled?: boolean
|
|
21
|
+
/** Additional className */
|
|
22
|
+
className?: string
|
|
23
|
+
children?: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const colorStyles: Record<LinkColor, string> = {
|
|
27
|
+
default: 'text-text-link',
|
|
28
|
+
primary: 'text-brand-primary',
|
|
29
|
+
secondary: 'text-brand-secondary',
|
|
30
|
+
inherit: '',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const underlineStyles: Record<LinkUnderline, string> = {
|
|
34
|
+
always: 'underline',
|
|
35
|
+
hover: 'no-underline web:hover:underline',
|
|
36
|
+
none: 'no-underline',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Link component for navigation.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* <Link href="https://example.com">Visit Site</Link>
|
|
44
|
+
* <Link href="https://example.com" isExternal>External Link ↗</Link>
|
|
45
|
+
* <Link onPress={() => navigate('/page')}>Go to Page</Link>
|
|
46
|
+
*/
|
|
47
|
+
export function Link({
|
|
48
|
+
href,
|
|
49
|
+
underline = 'hover',
|
|
50
|
+
color = 'default',
|
|
51
|
+
isExternal = false,
|
|
52
|
+
onPress,
|
|
53
|
+
isDisabled = false,
|
|
54
|
+
className,
|
|
55
|
+
children,
|
|
56
|
+
...props
|
|
57
|
+
}: LinkProps) {
|
|
58
|
+
const handlePress = () => {
|
|
59
|
+
if (isDisabled) return
|
|
60
|
+
onPress?.()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Pressable
|
|
65
|
+
onPress={handlePress}
|
|
66
|
+
disabled={isDisabled}
|
|
67
|
+
accessibilityRole="link"
|
|
68
|
+
accessibilityHint={isExternal ? 'Opens in new window' : undefined}
|
|
69
|
+
>
|
|
70
|
+
<Text
|
|
71
|
+
className={cn(
|
|
72
|
+
'font-medium',
|
|
73
|
+
colorStyles[color],
|
|
74
|
+
underlineStyles[underline],
|
|
75
|
+
isDisabled && 'opacity-50 cursor-not-allowed',
|
|
76
|
+
'web:cursor-pointer active:opacity-70',
|
|
77
|
+
className
|
|
78
|
+
)}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
{isExternal && ' ↗'}
|
|
83
|
+
</Text>
|
|
84
|
+
</Pressable>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
ListItem,
|
|
5
|
+
ListItemIcon,
|
|
6
|
+
ListItemContent,
|
|
7
|
+
ListItemTrailing,
|
|
8
|
+
ListItemDivider,
|
|
9
|
+
} from './ListItem'
|
|
10
|
+
|
|
11
|
+
function UserIcon({ size = 20 }: { size?: number; className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: '#6366f1', justifyContent: 'center', alignItems: 'center' }}>
|
|
14
|
+
<Text style={{ color: '#fff', fontSize: size * 0.5, fontWeight: '600' }}>U</Text>
|
|
15
|
+
</View>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function WifiIcon({ size = 20 }: { size?: number; className?: string }) {
|
|
20
|
+
return (
|
|
21
|
+
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
|
|
22
|
+
<Text style={{ fontSize: size * 0.7 }}>W</Text>
|
|
23
|
+
</View>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function BellIcon({ size = 20 }: { size?: number; className?: string }) {
|
|
28
|
+
return (
|
|
29
|
+
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
|
|
30
|
+
<Text style={{ fontSize: size * 0.7 }}>B</Text>
|
|
31
|
+
</View>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function LockIcon({ size = 20 }: { size?: number; className?: string }) {
|
|
36
|
+
return (
|
|
37
|
+
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
|
|
38
|
+
<Text style={{ fontSize: size * 0.7 }}>L</Text>
|
|
39
|
+
</View>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ChevronRight({ size = 16 }: { size?: number }) {
|
|
44
|
+
return <Text style={{ color: '#9ca3af', fontSize: size }}>{'>'}</Text>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const meta: Meta<typeof ListItem> = {
|
|
48
|
+
title: 'Components/ListItem',
|
|
49
|
+
component: ListItem,
|
|
50
|
+
tags: ['autodocs'],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default meta
|
|
54
|
+
type Story = StoryObj<typeof ListItem>
|
|
55
|
+
|
|
56
|
+
export const Basic: Story = {
|
|
57
|
+
render: () => (
|
|
58
|
+
<ListItem>
|
|
59
|
+
<ListItemContent title="Simple list item" />
|
|
60
|
+
</ListItem>
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const WithSubtitle: Story = {
|
|
65
|
+
render: () => (
|
|
66
|
+
<ListItem>
|
|
67
|
+
<ListItemContent title="Wi-Fi" subtitle="Connected to HomeNetwork" />
|
|
68
|
+
</ListItem>
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const WithIcon: Story = {
|
|
73
|
+
render: () => (
|
|
74
|
+
<ListItem>
|
|
75
|
+
<ListItemIcon icon={WifiIcon} />
|
|
76
|
+
<ListItemContent title="Wi-Fi" subtitle="Connected" />
|
|
77
|
+
</ListItem>
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const WithTrailing: Story = {
|
|
82
|
+
render: () => (
|
|
83
|
+
<ListItem onPress={() => {}}>
|
|
84
|
+
<ListItemIcon icon={UserIcon} />
|
|
85
|
+
<ListItemContent title="Account" subtitle="Manage your account" />
|
|
86
|
+
<ListItemTrailing>
|
|
87
|
+
<ChevronRight />
|
|
88
|
+
</ListItemTrailing>
|
|
89
|
+
</ListItem>
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const SettingsList: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<View>
|
|
96
|
+
<ListItem onPress={() => {}}>
|
|
97
|
+
<ListItemIcon icon={UserIcon} />
|
|
98
|
+
<ListItemContent title="Profile" subtitle="Edit your profile details" />
|
|
99
|
+
<ListItemTrailing>
|
|
100
|
+
<ChevronRight />
|
|
101
|
+
</ListItemTrailing>
|
|
102
|
+
</ListItem>
|
|
103
|
+
<ListItemDivider />
|
|
104
|
+
|
|
105
|
+
<ListItem onPress={() => {}}>
|
|
106
|
+
<ListItemIcon icon={BellIcon} />
|
|
107
|
+
<ListItemContent title="Notifications" subtitle="Push, email, SMS" />
|
|
108
|
+
<ListItemTrailing>
|
|
109
|
+
<ChevronRight />
|
|
110
|
+
</ListItemTrailing>
|
|
111
|
+
</ListItem>
|
|
112
|
+
<ListItemDivider />
|
|
113
|
+
|
|
114
|
+
<ListItem onPress={() => {}}>
|
|
115
|
+
<ListItemIcon icon={LockIcon} />
|
|
116
|
+
<ListItemContent title="Privacy" subtitle="Manage your data" />
|
|
117
|
+
<ListItemTrailing>
|
|
118
|
+
<ChevronRight />
|
|
119
|
+
</ListItemTrailing>
|
|
120
|
+
</ListItem>
|
|
121
|
+
<ListItemDivider />
|
|
122
|
+
|
|
123
|
+
<ListItem onPress={() => {}}>
|
|
124
|
+
<ListItemIcon icon={WifiIcon} />
|
|
125
|
+
<ListItemContent title="Wi-Fi" subtitle="HomeNetwork" />
|
|
126
|
+
<ListItemTrailing>
|
|
127
|
+
<Text style={{ color: '#22c55e', fontSize: 12 }}>Connected</Text>
|
|
128
|
+
</ListItemTrailing>
|
|
129
|
+
</ListItem>
|
|
130
|
+
</View>
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const FullWidthDivider: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<View>
|
|
137
|
+
<ListItem>
|
|
138
|
+
<ListItemContent title="First item" />
|
|
139
|
+
</ListItem>
|
|
140
|
+
<ListItemDivider inset={false} />
|
|
141
|
+
<ListItem>
|
|
142
|
+
<ListItemContent title="Second item" />
|
|
143
|
+
</ListItem>
|
|
144
|
+
</View>
|
|
145
|
+
),
|
|
146
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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 { View, Text } from 'react-native'
|
|
5
|
+
import {
|
|
6
|
+
ListItem,
|
|
7
|
+
ListItemIcon,
|
|
8
|
+
ListItemContent,
|
|
9
|
+
ListItemTrailing,
|
|
10
|
+
ListItemDivider,
|
|
11
|
+
} from './ListItem'
|
|
12
|
+
|
|
13
|
+
function MockIcon({ size = 20 }: { size?: number; className?: string }) {
|
|
14
|
+
return <View testID="mock-icon" style={{ width: size, height: size }} />
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ChevronIcon({ size = 16 }: { size?: number; className?: string }) {
|
|
18
|
+
return <Text>{'>'}</Text>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('ListItem', () => {
|
|
22
|
+
it('renders compound children', () => {
|
|
23
|
+
render(
|
|
24
|
+
<ListItem>
|
|
25
|
+
<ListItemIcon icon={MockIcon} />
|
|
26
|
+
<ListItemContent title="Profile" subtitle="Edit your profile" />
|
|
27
|
+
<ListItemTrailing>
|
|
28
|
+
<Text>{'>'}</Text>
|
|
29
|
+
</ListItemTrailing>
|
|
30
|
+
</ListItem>
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
|
34
|
+
expect(screen.getByText('Profile')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByText('Edit your profile')).toBeInTheDocument()
|
|
36
|
+
expect(screen.getByText('>')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('handles press events when onPress is provided', () => {
|
|
40
|
+
const onPress = vi.fn()
|
|
41
|
+
render(
|
|
42
|
+
<ListItem onPress={onPress} testID="list-item">
|
|
43
|
+
<ListItemContent title="Pressable item" />
|
|
44
|
+
</ListItem>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
fireEvent.click(screen.getByTestId('list-item'))
|
|
48
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses View when onPress is not provided', () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<ListItem testID="list-item">
|
|
54
|
+
<ListItemContent title="Static item" />
|
|
55
|
+
</ListItem>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Without onPress, should not have button role
|
|
59
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
60
|
+
expect(screen.getByText('Static item')).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('ListItemContent', () => {
|
|
65
|
+
it('renders title', () => {
|
|
66
|
+
render(<ListItemContent title="Settings" />)
|
|
67
|
+
expect(screen.getByText('Settings')).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders title and subtitle', () => {
|
|
71
|
+
render(<ListItemContent title="Wi-Fi" subtitle="Connected" />)
|
|
72
|
+
expect(screen.getByText('Wi-Fi')).toBeInTheDocument()
|
|
73
|
+
expect(screen.getByText('Connected')).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('omits subtitle when not provided', () => {
|
|
77
|
+
render(<ListItemContent title="Bluetooth" />)
|
|
78
|
+
expect(screen.getByText('Bluetooth')).toBeInTheDocument()
|
|
79
|
+
expect(screen.queryByText('Connected')).not.toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('ListItemTrailing', () => {
|
|
84
|
+
it('renders trailing content', () => {
|
|
85
|
+
render(
|
|
86
|
+
<ListItemTrailing>
|
|
87
|
+
<Text>Badge</Text>
|
|
88
|
+
</ListItemTrailing>
|
|
89
|
+
)
|
|
90
|
+
expect(screen.getByText('Badge')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('ListItemDivider', () => {
|
|
95
|
+
it('renders with inset by default', () => {
|
|
96
|
+
const { container } = render(<ListItemDivider testID="divider" />)
|
|
97
|
+
expect(screen.getByTestId('divider')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders full-width when inset is false', () => {
|
|
101
|
+
render(<ListItemDivider inset={false} testID="divider-full" />)
|
|
102
|
+
expect(screen.getByTestId('divider-full')).toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('ListItem accessibility', () => {
|
|
107
|
+
it('has no accessibility violations', async () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<ListItem>
|
|
110
|
+
<ListItemContent title="Accessible item" subtitle="With subtitle" />
|
|
111
|
+
</ListItem>
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const results = await axe(container)
|
|
115
|
+
expect(results).toHaveNoViolations()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('has no accessibility violations when pressable', async () => {
|
|
119
|
+
const { container } = render(
|
|
120
|
+
<ListItem onPress={() => {}} accessibilityRole="button">
|
|
121
|
+
<ListItemContent title="Pressable item" />
|
|
122
|
+
</ListItem>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const results = await axe(container)
|
|
126
|
+
expect(results).toHaveNoViolations()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, Pressable, type ViewProps, type PressableProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
// --- ListItem (container) ---
|
|
6
|
+
|
|
7
|
+
export interface ListItemProps extends PressableProps {
|
|
8
|
+
className?: string
|
|
9
|
+
children: React.ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ListItem({ className, children, onPress, ...props }: ListItemProps) {
|
|
13
|
+
const Container = onPress ? Pressable : View
|
|
14
|
+
const containerProps = onPress ? { onPress, ...props } : props
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Container
|
|
18
|
+
className={cn('flex-row items-center py-3 px-4 min-h-[48px]', className)}
|
|
19
|
+
{...(containerProps as any)}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</Container>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- ListItemIcon (leading slot) ---
|
|
27
|
+
|
|
28
|
+
export interface ListItemIconProps extends ViewProps {
|
|
29
|
+
icon: React.ComponentType<{ size?: number; className?: string }>
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ListItemIcon({ icon: Icon, className, ...props }: ListItemIconProps) {
|
|
34
|
+
return (
|
|
35
|
+
<View className={cn('mr-3 items-center justify-center', className)} {...props}>
|
|
36
|
+
<Icon size={20} className="text-text-secondary" />
|
|
37
|
+
</View>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- ListItemContent (title + subtitle) ---
|
|
42
|
+
|
|
43
|
+
export interface ListItemContentProps extends ViewProps {
|
|
44
|
+
title: string
|
|
45
|
+
subtitle?: string
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ListItemContent({ title, subtitle, className, ...props }: ListItemContentProps) {
|
|
50
|
+
return (
|
|
51
|
+
<View className={cn('flex-1 justify-center', className)} {...props}>
|
|
52
|
+
<Text className="text-sm font-medium text-text-primary">{title}</Text>
|
|
53
|
+
{subtitle && (
|
|
54
|
+
<Text className="text-xs text-text-secondary mt-0.5">{subtitle}</Text>
|
|
55
|
+
)}
|
|
56
|
+
</View>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- ListItemTrailing (right-side slot) ---
|
|
61
|
+
|
|
62
|
+
export interface ListItemTrailingProps extends ViewProps {
|
|
63
|
+
className?: string
|
|
64
|
+
children: React.ReactNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ListItemTrailing({ className, children, ...props }: ListItemTrailingProps) {
|
|
68
|
+
return (
|
|
69
|
+
<View className={cn('ml-3 items-center justify-center', className)} {...props}>
|
|
70
|
+
{children}
|
|
71
|
+
</View>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- ListItemDivider ---
|
|
76
|
+
|
|
77
|
+
export interface ListItemDividerProps extends ViewProps {
|
|
78
|
+
inset?: boolean
|
|
79
|
+
className?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ListItemDivider({ inset = true, className, ...props }: ListItemDividerProps) {
|
|
83
|
+
return (
|
|
84
|
+
<View
|
|
85
|
+
className={cn('h-px bg-divider', inset ? 'ml-14' : '', className)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ListItem,
|
|
3
|
+
ListItemIcon,
|
|
4
|
+
ListItemContent,
|
|
5
|
+
ListItemTrailing,
|
|
6
|
+
ListItemDivider,
|
|
7
|
+
} from './ListItem'
|
|
8
|
+
export type {
|
|
9
|
+
ListItemProps,
|
|
10
|
+
ListItemIconProps,
|
|
11
|
+
ListItemContentProps,
|
|
12
|
+
ListItemTrailingProps,
|
|
13
|
+
ListItemDividerProps,
|
|
14
|
+
} from './ListItem'
|