@titan-design/react-ui 0.0.2 → 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/dist/{chunk-NA3EES23.mjs → chunk-MGW37MPO.mjs} +26 -4
- package/dist/chunk-MGW37MPO.mjs.map +1 -0
- package/dist/index.d.mts +115 -2
- package/dist/index.d.ts +115 -2
- package/dist/index.js +262 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +225 -3
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +124 -109
- package/dist/theme/index.d.ts +124 -109
- package/dist/theme/index.js +24 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1 -1
- package/package.json +12 -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
- package/dist/chunk-NA3EES23.mjs.map +0 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import React, { forwardRef, useState } from 'react'
|
|
2
|
+
import { TextInput, View, Text, Pressable, type TextInputProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export type InputSize = 'sm' | 'md' | 'lg'
|
|
6
|
+
export type InputVariant = 'outline' | 'filled' | 'underline'
|
|
7
|
+
|
|
8
|
+
export interface InputProps extends Omit<TextInputProps, 'editable'> {
|
|
9
|
+
/** Input size */
|
|
10
|
+
size?: InputSize
|
|
11
|
+
/** Visual variant */
|
|
12
|
+
variant?: InputVariant
|
|
13
|
+
/** Whether the input is disabled */
|
|
14
|
+
isDisabled?: boolean
|
|
15
|
+
/** Whether the input has an error */
|
|
16
|
+
isInvalid?: boolean
|
|
17
|
+
/** Whether the input is read-only */
|
|
18
|
+
isReadOnly?: boolean
|
|
19
|
+
/** Whether the input is required */
|
|
20
|
+
isRequired?: boolean
|
|
21
|
+
/** Whether this is a multiline textarea */
|
|
22
|
+
multiline?: boolean
|
|
23
|
+
/** Number of visible lines for multiline */
|
|
24
|
+
numberOfLines?: number
|
|
25
|
+
/** Label text */
|
|
26
|
+
label?: string
|
|
27
|
+
/** Helper text shown below input */
|
|
28
|
+
helperText?: string
|
|
29
|
+
/** Error message (shown when isInvalid is true) */
|
|
30
|
+
errorMessage?: string
|
|
31
|
+
/** Left element/icon */
|
|
32
|
+
leftElement?: React.ReactNode
|
|
33
|
+
/** Right element/icon */
|
|
34
|
+
rightElement?: React.ReactNode
|
|
35
|
+
/** Additional className for the container */
|
|
36
|
+
className?: string
|
|
37
|
+
/** Additional className for the input */
|
|
38
|
+
inputClassName?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sizeStyles: Record<InputSize, string> = {
|
|
42
|
+
sm: 'h-8 px-3 text-sm',
|
|
43
|
+
md: 'h-10 px-4 text-base',
|
|
44
|
+
lg: 'h-12 px-4 text-lg',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const multilineSizeStyles: Record<InputSize, string> = {
|
|
48
|
+
sm: 'min-h-[80px] px-3 py-2 text-sm',
|
|
49
|
+
md: 'min-h-[100px] px-4 py-3 text-base',
|
|
50
|
+
lg: 'min-h-[120px] px-4 py-3 text-lg',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const variantStyles: Record<InputVariant, { base: string; focus: string; hover: string; error: string }> = {
|
|
54
|
+
outline: {
|
|
55
|
+
base: 'border border-border-input rounded-md bg-transparent',
|
|
56
|
+
hover: 'web:hover:border-border-input-hover',
|
|
57
|
+
focus: 'border-border-input-focus',
|
|
58
|
+
error: 'border-border-input-error',
|
|
59
|
+
},
|
|
60
|
+
filled: {
|
|
61
|
+
base: 'border border-transparent rounded-md bg-surface-input',
|
|
62
|
+
hover: 'web:hover:border-border-input-hover',
|
|
63
|
+
focus: 'border-border-input-focus bg-transparent',
|
|
64
|
+
error: 'border-border-input-error',
|
|
65
|
+
},
|
|
66
|
+
underline: {
|
|
67
|
+
base: 'border-b border-border-input rounded-none bg-transparent',
|
|
68
|
+
hover: 'web:hover:border-border-input-hover',
|
|
69
|
+
focus: 'border-border-input-focus',
|
|
70
|
+
error: 'border-border-input-error',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Input component for text entry.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* <Input
|
|
79
|
+
* label="Email"
|
|
80
|
+
* placeholder="Enter your email"
|
|
81
|
+
* helperText="We'll never share your email"
|
|
82
|
+
* />
|
|
83
|
+
*
|
|
84
|
+
* // With error state
|
|
85
|
+
* <Input
|
|
86
|
+
* label="Password"
|
|
87
|
+
* isInvalid
|
|
88
|
+
* errorMessage="Password is required"
|
|
89
|
+
* />
|
|
90
|
+
*/
|
|
91
|
+
export const Input = forwardRef<TextInput, InputProps>(function Input(
|
|
92
|
+
{
|
|
93
|
+
size = 'md',
|
|
94
|
+
variant = 'outline',
|
|
95
|
+
isDisabled = false,
|
|
96
|
+
isInvalid = false,
|
|
97
|
+
isReadOnly = false,
|
|
98
|
+
isRequired = false,
|
|
99
|
+
multiline = false,
|
|
100
|
+
numberOfLines = 4,
|
|
101
|
+
label,
|
|
102
|
+
helperText,
|
|
103
|
+
errorMessage,
|
|
104
|
+
leftElement,
|
|
105
|
+
rightElement,
|
|
106
|
+
className,
|
|
107
|
+
inputClassName,
|
|
108
|
+
onFocus,
|
|
109
|
+
onBlur,
|
|
110
|
+
...props
|
|
111
|
+
},
|
|
112
|
+
ref
|
|
113
|
+
) {
|
|
114
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
115
|
+
|
|
116
|
+
const handleFocus = (e: any) => {
|
|
117
|
+
setIsFocused(true)
|
|
118
|
+
onFocus?.(e)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const handleBlur = (e: any) => {
|
|
122
|
+
setIsFocused(false)
|
|
123
|
+
onBlur?.(e)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const showError = isInvalid && errorMessage
|
|
127
|
+
const showHelper = !showError && helperText
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<View className={cn('w-full', className)}>
|
|
131
|
+
{label && (
|
|
132
|
+
<Text className="mb-1.5 text-sm font-medium text-text-primary">
|
|
133
|
+
{label}
|
|
134
|
+
{isRequired && <Text className="text-status-error ml-0.5">*</Text>}
|
|
135
|
+
</Text>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
<View
|
|
139
|
+
className={cn(
|
|
140
|
+
'flex-row transition-colors duration-150',
|
|
141
|
+
multiline ? 'items-start' : 'items-center',
|
|
142
|
+
variantStyles[variant].base,
|
|
143
|
+
!isDisabled && !isInvalid && variantStyles[variant].hover,
|
|
144
|
+
isFocused && variantStyles[variant].focus,
|
|
145
|
+
isInvalid && variantStyles[variant].error,
|
|
146
|
+
isDisabled && 'opacity-40 cursor-not-allowed'
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{leftElement && (
|
|
150
|
+
<View className={cn('pl-3 pr-1', multiline && 'pt-3')}>
|
|
151
|
+
{leftElement}
|
|
152
|
+
</View>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<TextInput
|
|
156
|
+
ref={ref}
|
|
157
|
+
editable={!isDisabled && !isReadOnly}
|
|
158
|
+
accessibilityLabel={label}
|
|
159
|
+
accessibilityState={{
|
|
160
|
+
disabled: isDisabled,
|
|
161
|
+
}}
|
|
162
|
+
multiline={multiline}
|
|
163
|
+
numberOfLines={multiline ? numberOfLines : 1}
|
|
164
|
+
textAlignVertical={multiline ? 'top' : 'center'}
|
|
165
|
+
placeholderTextColor="var(--color-text-tertiary)"
|
|
166
|
+
onFocus={handleFocus}
|
|
167
|
+
onBlur={handleBlur}
|
|
168
|
+
className={cn(
|
|
169
|
+
'flex-1 text-text-primary',
|
|
170
|
+
multiline ? multilineSizeStyles[size] : sizeStyles[size],
|
|
171
|
+
leftElement && 'pl-1',
|
|
172
|
+
rightElement && 'pr-1',
|
|
173
|
+
inputClassName
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
{rightElement && (
|
|
179
|
+
<View className={cn('pl-1 pr-3', multiline && 'pt-3')}>
|
|
180
|
+
{rightElement}
|
|
181
|
+
</View>
|
|
182
|
+
)}
|
|
183
|
+
</View>
|
|
184
|
+
|
|
185
|
+
{showHelper && (
|
|
186
|
+
<Text className="mt-1.5 text-xs text-text-secondary">
|
|
187
|
+
{helperText}
|
|
188
|
+
</Text>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{showError && (
|
|
192
|
+
<Text className="mt-1.5 text-xs text-status-error">
|
|
193
|
+
{errorMessage}
|
|
194
|
+
</Text>
|
|
195
|
+
)}
|
|
196
|
+
</View>
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
export interface InputGroupProps {
|
|
201
|
+
children: React.ReactNode
|
|
202
|
+
className?: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Container for grouping related inputs.
|
|
207
|
+
*/
|
|
208
|
+
export function InputGroup({ children, className }: InputGroupProps) {
|
|
209
|
+
return (
|
|
210
|
+
<View className={cn('flex-col gap-4', className)}>
|
|
211
|
+
{children}
|
|
212
|
+
</View>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface PasswordInputProps extends Omit<InputProps, 'secureTextEntry' | 'rightElement'> {
|
|
217
|
+
/** Custom show password icon */
|
|
218
|
+
showIcon?: React.ReactNode
|
|
219
|
+
/** Custom hide password icon */
|
|
220
|
+
hideIcon?: React.ReactNode
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Password input with visibility toggle.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* <PasswordInput
|
|
228
|
+
* label="Password"
|
|
229
|
+
* placeholder="Enter your password"
|
|
230
|
+
* />
|
|
231
|
+
*/
|
|
232
|
+
export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function PasswordInput(
|
|
233
|
+
{ showIcon, hideIcon, ...props },
|
|
234
|
+
ref
|
|
235
|
+
) {
|
|
236
|
+
const [isVisible, setIsVisible] = useState(false)
|
|
237
|
+
|
|
238
|
+
const toggleVisibility = () => setIsVisible(!isVisible)
|
|
239
|
+
|
|
240
|
+
// Default eye icons (simple SVG-like views)
|
|
241
|
+
const defaultShowIcon = (
|
|
242
|
+
<View className="w-5 h-5 items-center justify-center">
|
|
243
|
+
<View className="w-3 h-3 rounded-full border-2 border-text-secondary" />
|
|
244
|
+
<View className="absolute w-1.5 h-1.5 rounded-full bg-text-secondary" />
|
|
245
|
+
</View>
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const defaultHideIcon = (
|
|
249
|
+
<View className="w-5 h-5 items-center justify-center">
|
|
250
|
+
<View className="w-3 h-3 rounded-full border-2 border-text-secondary" />
|
|
251
|
+
<View className="absolute w-1.5 h-1.5 rounded-full bg-text-secondary" />
|
|
252
|
+
<View className="absolute w-5 h-0.5 bg-text-secondary rotate-45" />
|
|
253
|
+
</View>
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Input
|
|
258
|
+
ref={ref}
|
|
259
|
+
secureTextEntry={!isVisible}
|
|
260
|
+
rightElement={
|
|
261
|
+
<Pressable
|
|
262
|
+
onPress={toggleVisibility}
|
|
263
|
+
accessibilityRole="button"
|
|
264
|
+
accessibilityLabel={isVisible ? 'Hide password' : 'Show password'}
|
|
265
|
+
className="p-1 rounded active:opacity-70"
|
|
266
|
+
>
|
|
267
|
+
{isVisible ? (hideIcon || defaultHideIcon) : (showIcon || defaultShowIcon)}
|
|
268
|
+
</Pressable>
|
|
269
|
+
}
|
|
270
|
+
{...props}
|
|
271
|
+
/>
|
|
272
|
+
)
|
|
273
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import { Link } from './Link'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Link> = {
|
|
6
|
+
title: 'Components/Link',
|
|
7
|
+
component: Link,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
color: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['default', 'primary', 'secondary', 'inherit'],
|
|
13
|
+
description: 'Link color',
|
|
14
|
+
},
|
|
15
|
+
underline: {
|
|
16
|
+
control: 'select',
|
|
17
|
+
options: ['always', 'hover', 'none'],
|
|
18
|
+
description: 'Underline behavior',
|
|
19
|
+
},
|
|
20
|
+
isExternal: {
|
|
21
|
+
control: 'boolean',
|
|
22
|
+
description: 'Whether the link opens externally',
|
|
23
|
+
},
|
|
24
|
+
isDisabled: {
|
|
25
|
+
control: 'boolean',
|
|
26
|
+
description: 'Whether the link is disabled',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default meta
|
|
32
|
+
type Story = StoryObj<typeof Link>
|
|
33
|
+
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
children: 'Click here',
|
|
37
|
+
color: 'default',
|
|
38
|
+
underline: 'hover',
|
|
39
|
+
onPress: () => {},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const AllColors: Story = {
|
|
44
|
+
render: () => (
|
|
45
|
+
<View style={{ flexDirection: 'row', gap: 16 }}>
|
|
46
|
+
<Link color="default" onPress={() => {}}>Default</Link>
|
|
47
|
+
<Link color="primary" onPress={() => {}}>Primary</Link>
|
|
48
|
+
<Link color="secondary" onPress={() => {}}>Secondary</Link>
|
|
49
|
+
</View>
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const UnderlineStyles: Story = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<View style={{ flexDirection: 'row', gap: 16 }}>
|
|
56
|
+
<Link underline="always" onPress={() => {}}>Always</Link>
|
|
57
|
+
<Link underline="hover" onPress={() => {}}>Hover</Link>
|
|
58
|
+
<Link underline="none" onPress={() => {}}>None</Link>
|
|
59
|
+
</View>
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const External: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
children: 'Visit Example',
|
|
66
|
+
href: 'https://example.com',
|
|
67
|
+
isExternal: true,
|
|
68
|
+
onPress: () => {},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const Disabled: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
children: 'Disabled Link',
|
|
75
|
+
isDisabled: true,
|
|
76
|
+
onPress: () => {},
|
|
77
|
+
},
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|