@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,243 @@
|
|
|
1
|
+
import React, { useState, createContext, useContext } from 'react'
|
|
2
|
+
import { View, Text, Pressable, ScrollView, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface SelectOption<T = string> {
|
|
6
|
+
value: T
|
|
7
|
+
label: string
|
|
8
|
+
isDisabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SelectContextType<T = string> {
|
|
12
|
+
value: T | T[] | null
|
|
13
|
+
isMulti: boolean
|
|
14
|
+
isOpen: boolean
|
|
15
|
+
setIsOpen: (open: boolean) => void
|
|
16
|
+
selectValue: (val: T) => void
|
|
17
|
+
isSelected: (val: T) => boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SelectContext = createContext<SelectContextType<any>>({
|
|
21
|
+
value: null,
|
|
22
|
+
isMulti: false,
|
|
23
|
+
isOpen: false,
|
|
24
|
+
setIsOpen: () => {},
|
|
25
|
+
selectValue: () => {},
|
|
26
|
+
isSelected: () => false,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export interface SelectProps<T = string> extends ViewProps {
|
|
30
|
+
/** Selected value (single mode) */
|
|
31
|
+
value?: T | null
|
|
32
|
+
/** Selected values (multi mode) */
|
|
33
|
+
values?: T[]
|
|
34
|
+
/** Callback when value changes (single mode) */
|
|
35
|
+
onChange?: (value: T | null) => void
|
|
36
|
+
/** Callback when values change (multi mode) */
|
|
37
|
+
onChangeMulti?: (values: T[]) => void
|
|
38
|
+
/** Enable multi-select */
|
|
39
|
+
isMulti?: boolean
|
|
40
|
+
/** Placeholder text */
|
|
41
|
+
placeholder?: string
|
|
42
|
+
/** Whether the select is disabled */
|
|
43
|
+
isDisabled?: boolean
|
|
44
|
+
/** Whether the select has an error */
|
|
45
|
+
isInvalid?: boolean
|
|
46
|
+
/** Options to display */
|
|
47
|
+
options: SelectOption<T>[]
|
|
48
|
+
/** Visual variant — use 'filled' on dark/elevated surfaces */
|
|
49
|
+
variant?: 'default' | 'filled'
|
|
50
|
+
/** Additional className */
|
|
51
|
+
className?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Select component for single or multi-selection.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Single select
|
|
59
|
+
* <Select
|
|
60
|
+
* value={value}
|
|
61
|
+
* onChange={setValue}
|
|
62
|
+
* options={[
|
|
63
|
+
* { value: '1', label: 'Option 1' },
|
|
64
|
+
* { value: '2', label: 'Option 2' },
|
|
65
|
+
* ]}
|
|
66
|
+
* />
|
|
67
|
+
*
|
|
68
|
+
* // Multi select
|
|
69
|
+
* <Select
|
|
70
|
+
* isMulti
|
|
71
|
+
* values={values}
|
|
72
|
+
* onChangeMulti={setValues}
|
|
73
|
+
* options={options}
|
|
74
|
+
* />
|
|
75
|
+
*/
|
|
76
|
+
export function Select<T extends string = string>({
|
|
77
|
+
value,
|
|
78
|
+
values = [],
|
|
79
|
+
onChange,
|
|
80
|
+
onChangeMulti,
|
|
81
|
+
isMulti = false,
|
|
82
|
+
placeholder = 'Select...',
|
|
83
|
+
isDisabled = false,
|
|
84
|
+
isInvalid = false,
|
|
85
|
+
options,
|
|
86
|
+
variant = 'default',
|
|
87
|
+
className,
|
|
88
|
+
...props
|
|
89
|
+
}: SelectProps<T>) {
|
|
90
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
91
|
+
|
|
92
|
+
const selectValue = (val: T) => {
|
|
93
|
+
if (isMulti) {
|
|
94
|
+
const newValues = values.includes(val)
|
|
95
|
+
? values.filter((v) => v !== val)
|
|
96
|
+
: [...values, val]
|
|
97
|
+
onChangeMulti?.(newValues)
|
|
98
|
+
} else {
|
|
99
|
+
onChange?.(val)
|
|
100
|
+
setIsOpen(false)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isSelected = (val: T) => {
|
|
105
|
+
if (isMulti) {
|
|
106
|
+
return values.includes(val)
|
|
107
|
+
}
|
|
108
|
+
return value === val
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const getDisplayValue = () => {
|
|
112
|
+
if (isMulti) {
|
|
113
|
+
if (values.length === 0) return placeholder
|
|
114
|
+
if (values.length === 1) {
|
|
115
|
+
return options.find((o) => o.value === values[0])?.label || placeholder
|
|
116
|
+
}
|
|
117
|
+
return `${values.length} selected`
|
|
118
|
+
}
|
|
119
|
+
if (value === null || value === undefined) return placeholder
|
|
120
|
+
return options.find((o) => o.value === value)?.label || placeholder
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clearValue = () => {
|
|
124
|
+
if (isMulti) {
|
|
125
|
+
onChangeMulti?.([])
|
|
126
|
+
} else {
|
|
127
|
+
onChange?.(null)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hasValue = isMulti ? values.length > 0 : value !== null && value !== undefined
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<SelectContext.Provider value={{ value, isMulti, isOpen, setIsOpen, selectValue, isSelected }}>
|
|
135
|
+
<View className={cn('relative w-full', className)} {...props}>
|
|
136
|
+
{/* Trigger */}
|
|
137
|
+
<Pressable
|
|
138
|
+
onPress={() => !isDisabled && setIsOpen(!isOpen)}
|
|
139
|
+
disabled={isDisabled}
|
|
140
|
+
accessibilityRole="combobox"
|
|
141
|
+
accessibilityState={{ expanded: isOpen, disabled: isDisabled }}
|
|
142
|
+
className={cn(
|
|
143
|
+
'flex-row items-center justify-between px-4 py-2.5 rounded-md border',
|
|
144
|
+
variant === 'filled' ? 'bg-black/30' : 'bg-surface-base',
|
|
145
|
+
isInvalid
|
|
146
|
+
? 'border-border-input-error'
|
|
147
|
+
: variant === 'filled'
|
|
148
|
+
? 'border-border-subtle'
|
|
149
|
+
: 'border-border-input',
|
|
150
|
+
!isDisabled && !isInvalid && 'web:hover:border-border-input-hover',
|
|
151
|
+
isOpen && 'border-border-input-focus',
|
|
152
|
+
isDisabled && 'opacity-50 cursor-not-allowed'
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<Text
|
|
156
|
+
className={cn(
|
|
157
|
+
'flex-1',
|
|
158
|
+
hasValue ? 'text-text-primary' : 'text-text-tertiary'
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{getDisplayValue()}
|
|
162
|
+
</Text>
|
|
163
|
+
<View className="flex-row items-center gap-2">
|
|
164
|
+
{hasValue && (
|
|
165
|
+
<Pressable
|
|
166
|
+
onPress={(e) => {
|
|
167
|
+
e.stopPropagation?.()
|
|
168
|
+
clearValue()
|
|
169
|
+
}}
|
|
170
|
+
className="p-1"
|
|
171
|
+
>
|
|
172
|
+
<Text className="text-text-secondary text-xs">×</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
)}
|
|
175
|
+
<Text className={cn('text-text-secondary', isOpen && 'rotate-180')}>▼</Text>
|
|
176
|
+
</View>
|
|
177
|
+
</Pressable>
|
|
178
|
+
|
|
179
|
+
{/* Dropdown */}
|
|
180
|
+
{isOpen && (
|
|
181
|
+
<>
|
|
182
|
+
<Pressable
|
|
183
|
+
onPress={() => setIsOpen(false)}
|
|
184
|
+
className="fixed inset-0 z-40"
|
|
185
|
+
style={{ position: 'absolute' }}
|
|
186
|
+
/>
|
|
187
|
+
<View
|
|
188
|
+
className={cn(
|
|
189
|
+
'absolute z-50 top-full left-0 right-0 mt-1',
|
|
190
|
+
'bg-surface-elevated rounded-md shadow-lg border border-border-default',
|
|
191
|
+
'max-h-60 overflow-hidden'
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
<ScrollView className="py-1">
|
|
195
|
+
{options.map((option) => (
|
|
196
|
+
<SelectOption key={option.value} option={option} />
|
|
197
|
+
))}
|
|
198
|
+
</ScrollView>
|
|
199
|
+
</View>
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</View>
|
|
203
|
+
</SelectContext.Provider>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface SelectOptionComponentProps<T> {
|
|
208
|
+
option: SelectOption<T>
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function SelectOption<T>({ option }: SelectOptionComponentProps<T>) {
|
|
212
|
+
const { selectValue, isSelected, isMulti } = useContext(SelectContext)
|
|
213
|
+
const selected = isSelected(option.value)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Pressable
|
|
217
|
+
onPress={() => !option.isDisabled && selectValue(option.value)}
|
|
218
|
+
disabled={option.isDisabled}
|
|
219
|
+
accessibilityRole={isMulti ? 'checkbox' : ('option' as any)}
|
|
220
|
+
accessibilityState={{ selected, disabled: option.isDisabled }}
|
|
221
|
+
className={cn(
|
|
222
|
+
'flex-row items-center px-4 py-2',
|
|
223
|
+
'web:hover:bg-interactive-hover active:bg-interactive-active',
|
|
224
|
+
selected && 'bg-interactive-selected',
|
|
225
|
+
option.isDisabled && 'opacity-50 cursor-not-allowed'
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
{isMulti && (
|
|
229
|
+
<View
|
|
230
|
+
className={cn(
|
|
231
|
+
'w-4 h-4 mr-3 rounded border',
|
|
232
|
+
selected ? 'bg-brand-primary border-brand-primary' : 'border-border-strong'
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{selected && <Text className="text-white text-xs text-center">✓</Text>}
|
|
236
|
+
</View>
|
|
237
|
+
)}
|
|
238
|
+
<Text className={cn('text-sm', selected && !isMulti ? 'text-brand-primary font-medium' : 'text-text-primary')}>
|
|
239
|
+
{option.label}
|
|
240
|
+
</Text>
|
|
241
|
+
</Pressable>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
Skeleton,
|
|
5
|
+
SkeletonText,
|
|
6
|
+
SkeletonCircle,
|
|
7
|
+
SkeletonCard,
|
|
8
|
+
SkeletonListItem,
|
|
9
|
+
} from './Skeleton'
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof Skeleton> = {
|
|
12
|
+
title: 'Components/Skeleton',
|
|
13
|
+
component: Skeleton,
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
argTypes: {
|
|
16
|
+
variant: {
|
|
17
|
+
control: 'select',
|
|
18
|
+
options: ['text', 'circle', 'rect', 'rounded'],
|
|
19
|
+
},
|
|
20
|
+
animation: {
|
|
21
|
+
control: 'select',
|
|
22
|
+
options: ['pulse', 'shimmer', 'none'],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof Skeleton>
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
variant: 'text',
|
|
33
|
+
width: 200,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const TextVariant: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<View style={{ gap: 8, width: 300 }}>
|
|
40
|
+
<Skeleton variant="text" width="100%" />
|
|
41
|
+
<Skeleton variant="text" width="80%" />
|
|
42
|
+
<Skeleton variant="text" width="60%" />
|
|
43
|
+
</View>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const CircleVariant: Story = {
|
|
48
|
+
render: () => (
|
|
49
|
+
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
50
|
+
<Skeleton variant="circle" width={32} height={32} />
|
|
51
|
+
<Skeleton variant="circle" width={48} height={48} />
|
|
52
|
+
<Skeleton variant="circle" width={64} height={64} />
|
|
53
|
+
</View>
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const RectVariant: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<View style={{ gap: 12 }}>
|
|
60
|
+
<Skeleton variant="rect" width={200} height={100} />
|
|
61
|
+
<Skeleton variant="rect" width={200} height={100} borderRadius={8} />
|
|
62
|
+
</View>
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const RoundedVariant: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<Skeleton variant="rounded" width={300} height={200} />
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const TextBlock: Story = {
|
|
73
|
+
render: () => (
|
|
74
|
+
<View style={{ width: 300 }}>
|
|
75
|
+
<SkeletonText lines={4} />
|
|
76
|
+
</View>
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const Avatar: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
83
|
+
<SkeletonCircle size={40} />
|
|
84
|
+
<SkeletonCircle size={48} />
|
|
85
|
+
<SkeletonCircle size={56} />
|
|
86
|
+
</View>
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const CardSkeleton: Story = {
|
|
91
|
+
render: () => (
|
|
92
|
+
<View style={{ width: 300 }}>
|
|
93
|
+
<SkeletonCard />
|
|
94
|
+
</View>
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const CardWithoutImage: Story = {
|
|
99
|
+
render: () => (
|
|
100
|
+
<View style={{ width: 300 }}>
|
|
101
|
+
<SkeletonCard hasImage={false} />
|
|
102
|
+
</View>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const ListItemSkeleton: Story = {
|
|
107
|
+
render: () => (
|
|
108
|
+
<View style={{ width: 350 }}>
|
|
109
|
+
<SkeletonListItem />
|
|
110
|
+
<SkeletonListItem />
|
|
111
|
+
<SkeletonListItem />
|
|
112
|
+
</View>
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const ListWithoutAvatar: Story = {
|
|
117
|
+
render: () => (
|
|
118
|
+
<View style={{ width: 350 }}>
|
|
119
|
+
<SkeletonListItem hasAvatar={false} />
|
|
120
|
+
<SkeletonListItem hasAvatar={false} />
|
|
121
|
+
<SkeletonListItem hasAvatar={false} />
|
|
122
|
+
</View>
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const NoAnimation: Story = {
|
|
127
|
+
render: () => (
|
|
128
|
+
<View style={{ gap: 12 }}>
|
|
129
|
+
<Skeleton variant="text" width={200} animation="none" />
|
|
130
|
+
<SkeletonCircle size={48} animation="none" />
|
|
131
|
+
<Skeleton variant="rounded" width={200} height={100} animation="none" />
|
|
132
|
+
</View>
|
|
133
|
+
),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const ComposedExample: Story = {
|
|
137
|
+
render: () => (
|
|
138
|
+
<View className="w-[400px] p-4 bg-surface-elevated rounded-xl">
|
|
139
|
+
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 16 }}>
|
|
140
|
+
<SkeletonCircle size={56} />
|
|
141
|
+
<View style={{ flex: 1, justifyContent: 'center', gap: 8 }}>
|
|
142
|
+
<Skeleton variant="text" width="60%" />
|
|
143
|
+
<Skeleton variant="text" width="40%" />
|
|
144
|
+
</View>
|
|
145
|
+
</View>
|
|
146
|
+
<SkeletonText lines={3} />
|
|
147
|
+
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16 }}>
|
|
148
|
+
<Skeleton variant="rounded" width={80} height={36} />
|
|
149
|
+
<Skeleton variant="rounded" width={80} height={36} />
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
),
|
|
153
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Skeleton, SkeletonText, SkeletonCircle, SkeletonCard, SkeletonListItem } from './Skeleton'
|
|
5
|
+
|
|
6
|
+
describe('Skeleton', () => {
|
|
7
|
+
it('renders correctly', () => {
|
|
8
|
+
const { container } = render(<Skeleton />)
|
|
9
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('defaults to text variant with pulse animation', () => {
|
|
13
|
+
const { container } = render(<Skeleton />)
|
|
14
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders with all variant options', () => {
|
|
18
|
+
const variants = ['text', 'circle', 'rect', 'rounded'] as const
|
|
19
|
+
variants.forEach((variant) => {
|
|
20
|
+
const { unmount } = render(<Skeleton variant={variant} />)
|
|
21
|
+
unmount()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders with all animation options', () => {
|
|
26
|
+
const animations = ['pulse', 'shimmer', 'none'] as const
|
|
27
|
+
animations.forEach((animation) => {
|
|
28
|
+
const { unmount } = render(<Skeleton animation={animation} />)
|
|
29
|
+
unmount()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts width and height props', () => {
|
|
34
|
+
const { container } = render(<Skeleton width={200} height={100} />)
|
|
35
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('accepts string width and height', () => {
|
|
39
|
+
const { container } = render(<Skeleton width="50%" height="100px" />)
|
|
40
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('makes circle variant square when only width is provided', () => {
|
|
44
|
+
const { container } = render(<Skeleton variant="circle" width={48} />)
|
|
45
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('makes circle variant square when only height is provided', () => {
|
|
49
|
+
const { container } = render(<Skeleton variant="circle" height={48} />)
|
|
50
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('accepts custom borderRadius', () => {
|
|
54
|
+
const { container } = render(<Skeleton variant="rect" borderRadius={8} />)
|
|
55
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('applies custom className', () => {
|
|
59
|
+
const { container } = render(<Skeleton className="extra" />)
|
|
60
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('has accessibility label of Loading...', () => {
|
|
64
|
+
const { container } = render(<Skeleton />)
|
|
65
|
+
expect(container.firstChild).toHaveAttribute('aria-label', 'Loading...')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('SkeletonText', () => {
|
|
69
|
+
it('renders default 3 lines', () => {
|
|
70
|
+
const { container } = render(<SkeletonText />)
|
|
71
|
+
// 3 skeleton lines
|
|
72
|
+
expect(container.firstChild?.childNodes).toHaveLength(3)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders custom number of lines', () => {
|
|
76
|
+
const { container } = render(<SkeletonText lines={5} />)
|
|
77
|
+
expect(container.firstChild?.childNodes).toHaveLength(5)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('accepts custom lastLineWidth', () => {
|
|
81
|
+
const { container } = render(<SkeletonText lastLineWidth="40%" />)
|
|
82
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('accepts custom animation', () => {
|
|
86
|
+
const { container } = render(<SkeletonText animation="none" />)
|
|
87
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('SkeletonCircle', () => {
|
|
92
|
+
it('renders with default size', () => {
|
|
93
|
+
const { container } = render(<SkeletonCircle />)
|
|
94
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('renders with custom size', () => {
|
|
98
|
+
const { container } = render(<SkeletonCircle size={64} />)
|
|
99
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('SkeletonCard', () => {
|
|
104
|
+
it('renders with default props', () => {
|
|
105
|
+
const { container } = render(<SkeletonCard />)
|
|
106
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('renders without image', () => {
|
|
110
|
+
const { container } = render(<SkeletonCard hasImage={false} />)
|
|
111
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('accepts custom imageHeight and lines', () => {
|
|
115
|
+
const { container } = render(<SkeletonCard imageHeight={200} lines={4} />)
|
|
116
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('SkeletonListItem', () => {
|
|
121
|
+
it('renders with default props', () => {
|
|
122
|
+
const { container } = render(<SkeletonListItem />)
|
|
123
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders without avatar', () => {
|
|
127
|
+
const { container } = render(<SkeletonListItem hasAvatar={false} />)
|
|
128
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('accepts custom avatarSize and lines', () => {
|
|
132
|
+
const { container } = render(<SkeletonListItem avatarSize={56} lines={3} />)
|
|
133
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('accessibility', () => {
|
|
138
|
+
it('has no accessibility violations', async () => {
|
|
139
|
+
const { container } = render(<Skeleton />)
|
|
140
|
+
const results = await axe(container, {
|
|
141
|
+
rules: { 'aria-prohibited-attr': { enabled: false } },
|
|
142
|
+
})
|
|
143
|
+
expect(results).toHaveNoViolations()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('has no accessibility violations for SkeletonText', async () => {
|
|
147
|
+
const { container } = render(<SkeletonText />)
|
|
148
|
+
const results = await axe(container, {
|
|
149
|
+
rules: { 'aria-prohibited-attr': { enabled: false } },
|
|
150
|
+
})
|
|
151
|
+
expect(results).toHaveNoViolations()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('has no accessibility violations for SkeletonCard', async () => {
|
|
155
|
+
const { container } = render(<SkeletonCard />)
|
|
156
|
+
const results = await axe(container, {
|
|
157
|
+
rules: { 'aria-prohibited-attr': { enabled: false } },
|
|
158
|
+
})
|
|
159
|
+
expect(results).toHaveNoViolations()
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|