@elvora/react-native 1.0.0-rc.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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +5785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1253 -0
- package/dist/index.d.ts +1253 -0
- package/dist/index.js +5683 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -0
- package/src/Accordion.tsx +11 -0
- package/src/Affix.tsx +20 -0
- package/src/Alert.tsx +102 -0
- package/src/Anchor.tsx +58 -0
- package/src/AutoComplete.tsx +122 -0
- package/src/Avatar.tsx +58 -0
- package/src/BackTop.tsx +71 -0
- package/src/Backdrop.tsx +32 -0
- package/src/Badge.tsx +87 -0
- package/src/Box.tsx +67 -0
- package/src/Breadcrumb.tsx +46 -0
- package/src/Button.test.tsx +39 -0
- package/src/Button.tsx +127 -0
- package/src/ButtonGroup.tsx +74 -0
- package/src/Calendar.tsx +165 -0
- package/src/Card.tsx +69 -0
- package/src/Carousel.tsx +99 -0
- package/src/Cascader.tsx +160 -0
- package/src/Checkbox.tsx +85 -0
- package/src/ChipInput.tsx +130 -0
- package/src/Collapse.tsx +120 -0
- package/src/ColorPicker.tsx +114 -0
- package/src/Container.tsx +22 -0
- package/src/DataGrid.tsx +170 -0
- package/src/DatePicker.tsx +195 -0
- package/src/DateRangePicker.tsx +249 -0
- package/src/Descriptions.tsx +98 -0
- package/src/Divider.tsx +32 -0
- package/src/Drawer.tsx +103 -0
- package/src/Dropdown.tsx +15 -0
- package/src/ElvoraProvider.tsx +31 -0
- package/src/Empty.tsx +34 -0
- package/src/FloatButton.tsx +78 -0
- package/src/Form.tsx +119 -0
- package/src/Grid.tsx +68 -0
- package/src/Icon.tsx +49 -0
- package/src/IconButton.tsx +28 -0
- package/src/Image.tsx +68 -0
- package/src/ImageList.tsx +58 -0
- package/src/Input.tsx +87 -0
- package/src/Label.tsx +46 -0
- package/src/List.tsx +82 -0
- package/src/Mentions.tsx +148 -0
- package/src/Menu.tsx +77 -0
- package/src/Modal.tsx +114 -0
- package/src/NumberInput.tsx +156 -0
- package/src/Pagination.tsx +148 -0
- package/src/PaginationVariants.tsx +64 -0
- package/src/Popover.tsx +74 -0
- package/src/ProForm.tsx +219 -0
- package/src/ProLayout.tsx +151 -0
- package/src/ProTable.tsx +91 -0
- package/src/Progress.tsx +92 -0
- package/src/QRCode.tsx +65 -0
- package/src/Radio.tsx +98 -0
- package/src/Rate.tsx +66 -0
- package/src/Result.tsx +64 -0
- package/src/Segmented.tsx +75 -0
- package/src/Select.tsx +146 -0
- package/src/Skeleton.tsx +49 -0
- package/src/Slider.tsx +122 -0
- package/src/SpeedDial.tsx +87 -0
- package/src/Spinner.tsx +29 -0
- package/src/Splitter.tsx +91 -0
- package/src/Stack.tsx +38 -0
- package/src/Statistic.tsx +60 -0
- package/src/Stepper.tsx +113 -0
- package/src/Steps.tsx +146 -0
- package/src/Switch.tsx +52 -0
- package/src/Table.tsx +178 -0
- package/src/Tabs.tsx +122 -0
- package/src/Tag.tsx +83 -0
- package/src/Textarea.tsx +22 -0
- package/src/TimePicker.tsx +187 -0
- package/src/Timeline.tsx +92 -0
- package/src/Toast.tsx +140 -0
- package/src/ToggleButton.tsx +66 -0
- package/src/Tooltip.tsx +56 -0
- package/src/Tour.tsx +118 -0
- package/src/Transfer.tsx +219 -0
- package/src/Tree.tsx +144 -0
- package/src/TreeSelect.tsx +221 -0
- package/src/Upload.tsx +109 -0
- package/src/Watermark.tsx +76 -0
- package/src/index.ts +221 -0
- package/src/smoke.test.tsx +113 -0
- package/src/test/react-native-stub.tsx +413 -0
- package/src/test/react-native-svg-stub.tsx +33 -0
- package/src/test/setup.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elvora/react-native",
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
|
+
"description": "Elvora UI components for React Native — same headless API as the web adapters, native rendering, WCAG 2.1 AA.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Elvora UI Contributors",
|
|
7
|
+
"homepage": "https://github.com/elvora-ui/elvora/tree/main/packages/react-native#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/elvora-ui/elvora.git",
|
|
11
|
+
"directory": "packages/react-native"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/elvora-ui/elvora/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"ios",
|
|
19
|
+
"android",
|
|
20
|
+
"ui",
|
|
21
|
+
"design-system",
|
|
22
|
+
"headless",
|
|
23
|
+
"accessibility",
|
|
24
|
+
"elvora"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"react-native": "./src/index.ts",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"react-native": "./src/index.ts",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js",
|
|
36
|
+
"require": "./dist/index.cjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src"
|
|
42
|
+
],
|
|
43
|
+
"sideEffects": false,
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": ">=18.0.0",
|
|
46
|
+
"react-native": ">=0.74.0",
|
|
47
|
+
"react-native-svg": ">=15.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react-native-svg": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@elvora/icons": "1.0.0-rc.1",
|
|
56
|
+
"@elvora/core": "1.0.0-rc.1",
|
|
57
|
+
"@elvora/themes": "1.0.0-rc.1",
|
|
58
|
+
"@elvora/tokens": "1.0.0-rc.1"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
62
|
+
"@testing-library/react": "^16.1.0",
|
|
63
|
+
"@types/react": "^18.3.18",
|
|
64
|
+
"@types/react-dom": "^18.3.5",
|
|
65
|
+
"jsdom": "^25.0.1",
|
|
66
|
+
"react": "^18.3.1",
|
|
67
|
+
"react-dom": "^18.3.1",
|
|
68
|
+
"react-native": "0.76.5",
|
|
69
|
+
"react-native-svg": "^15.8.0",
|
|
70
|
+
"react-test-renderer": "^18.3.1",
|
|
71
|
+
"rimraf": "^6.0.1",
|
|
72
|
+
"tsup": "^8.3.5",
|
|
73
|
+
"typescript": "^5.7.2",
|
|
74
|
+
"vitest": "^2.1.8"
|
|
75
|
+
},
|
|
76
|
+
"publishConfig": {
|
|
77
|
+
"access": "public"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "tsup",
|
|
81
|
+
"dev": "tsup --watch",
|
|
82
|
+
"typecheck": "tsc --noEmit",
|
|
83
|
+
"lint": "echo 'no lint configured'",
|
|
84
|
+
"test": "vitest run",
|
|
85
|
+
"test:watch": "vitest",
|
|
86
|
+
"clean": "rimraf dist"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Collapse, type CollapseItem, type CollapseProps } from './Collapse';
|
|
2
|
+
|
|
3
|
+
export interface AccordionItem extends CollapseItem {}
|
|
4
|
+
|
|
5
|
+
export interface AccordionProps extends Omit<CollapseProps, 'accordion' | 'items'> {
|
|
6
|
+
items: AccordionItem[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Accordion(props: AccordionProps) {
|
|
10
|
+
return <Collapse {...props} accordion />;
|
|
11
|
+
}
|
package/src/Affix.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { View, type ViewProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface AffixProps extends ViewProps {
|
|
5
|
+
/** No-op on RN — use sticky list headers instead. Pass-through wrapper. */
|
|
6
|
+
offsetTop?: number;
|
|
7
|
+
offsetBottom?: number;
|
|
8
|
+
onChange?: (affixed: boolean) => void;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Affix — on React Native there is no global scroll viewport so this is a
|
|
14
|
+
* pass-through wrapper. For sticky list headers use `FlatList`'s
|
|
15
|
+
* `stickyHeaderIndices` or `ScrollView`'s `stickyHeaderIndices` directly.
|
|
16
|
+
*/
|
|
17
|
+
export function Affix(props: AffixProps) {
|
|
18
|
+
const { children, offsetTop: _o, offsetBottom: _b, onChange: _c, ...rest } = props;
|
|
19
|
+
return <View {...rest}>{children}</View>;
|
|
20
|
+
}
|
package/src/Alert.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, type ViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraStatus, ElvoraTone } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import { IconButton } from './IconButton';
|
|
7
|
+
import type { IconName } from '@elvora/icons';
|
|
8
|
+
|
|
9
|
+
export interface AlertProps extends ViewProps {
|
|
10
|
+
status?: ElvoraStatus;
|
|
11
|
+
tone?: ElvoraTone;
|
|
12
|
+
title?: ReactNode;
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
closeLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const statusIntent = {
|
|
20
|
+
neutral: 'neutral',
|
|
21
|
+
info: 'info',
|
|
22
|
+
success: 'success',
|
|
23
|
+
warning: 'warning',
|
|
24
|
+
error: 'danger',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const defaultIcon: Record<ElvoraStatus, IconName> = {
|
|
28
|
+
neutral: 'info',
|
|
29
|
+
info: 'info',
|
|
30
|
+
success: 'checkCircle',
|
|
31
|
+
warning: 'alertCircle',
|
|
32
|
+
error: 'x',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Alert — banner-style feedback for RN. */
|
|
36
|
+
export const Alert = forwardRef<View, AlertProps>(function Alert(props, ref) {
|
|
37
|
+
const {
|
|
38
|
+
status = 'info',
|
|
39
|
+
tone = 'subtle',
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
icon,
|
|
43
|
+
onClose,
|
|
44
|
+
closeLabel = 'Dismiss',
|
|
45
|
+
children,
|
|
46
|
+
style,
|
|
47
|
+
...rest
|
|
48
|
+
} = props;
|
|
49
|
+
const theme = useTheme();
|
|
50
|
+
const intent = theme.colors.intent[statusIntent[status]];
|
|
51
|
+
let bg = intent.subtle;
|
|
52
|
+
let fg = intent.fg;
|
|
53
|
+
let borderColor = 'transparent';
|
|
54
|
+
if (tone === 'solid') {
|
|
55
|
+
bg = intent.solid;
|
|
56
|
+
fg = intent.solidFg;
|
|
57
|
+
} else if (tone === 'outline') {
|
|
58
|
+
bg = 'transparent';
|
|
59
|
+
fg = intent.fg;
|
|
60
|
+
borderColor = intent.border;
|
|
61
|
+
}
|
|
62
|
+
const base: ViewStyle = {
|
|
63
|
+
flexDirection: 'row',
|
|
64
|
+
alignItems: 'flex-start',
|
|
65
|
+
gap: 12,
|
|
66
|
+
paddingHorizontal: 16,
|
|
67
|
+
paddingVertical: 12,
|
|
68
|
+
borderRadius: theme.radii.md,
|
|
69
|
+
backgroundColor: bg,
|
|
70
|
+
borderWidth: tone === 'outline' ? 1 : 0,
|
|
71
|
+
borderColor,
|
|
72
|
+
};
|
|
73
|
+
return (
|
|
74
|
+
<View
|
|
75
|
+
ref={ref}
|
|
76
|
+
accessibilityRole={status === 'error' || status === 'warning' ? 'alert' : undefined}
|
|
77
|
+
accessibilityLiveRegion="polite"
|
|
78
|
+
style={[base, style]}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
<View style={{ marginTop: 2 }}>{icon ?? <Icon name={defaultIcon[status]} size={18} color={fg} />}</View>
|
|
82
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
83
|
+
{title ? (
|
|
84
|
+
<Text style={{ color: fg, fontWeight: '600', fontSize: 14, marginBottom: description || children ? 2 : 0 }}>
|
|
85
|
+
{title}
|
|
86
|
+
</Text>
|
|
87
|
+
) : null}
|
|
88
|
+
{description ? <Text style={{ color: fg, fontSize: 13, lineHeight: 18 }}>{description}</Text> : null}
|
|
89
|
+
{children}
|
|
90
|
+
</View>
|
|
91
|
+
{onClose ? (
|
|
92
|
+
<IconButton
|
|
93
|
+
accessibilityLabel={closeLabel}
|
|
94
|
+
size="xs"
|
|
95
|
+
variant="ghost"
|
|
96
|
+
onPress={onClose}
|
|
97
|
+
icon={<Icon name="x" size={14} color={fg} />}
|
|
98
|
+
/>
|
|
99
|
+
) : null}
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
});
|
package/src/Anchor.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode, type RefObject } from 'react';
|
|
2
|
+
import { Pressable, Text, View, type ViewProps, type ScrollView } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface AnchorLink {
|
|
6
|
+
/** Vertical scroll offset (px) for this section. */
|
|
7
|
+
offset: number;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AnchorProps extends ViewProps {
|
|
12
|
+
links: AnchorLink[];
|
|
13
|
+
/** Ref to the parent ScrollView used to scroll to each section. */
|
|
14
|
+
scrollRef: RefObject<ScrollView>;
|
|
15
|
+
activeIndex?: number;
|
|
16
|
+
smooth?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Anchor — section jumper for RN. Caller passes a ref to their parent
|
|
21
|
+
* `<ScrollView>` and pre-computed offsets for each section. Active highlighting
|
|
22
|
+
* is opt-in via `activeIndex`.
|
|
23
|
+
*/
|
|
24
|
+
export function Anchor(props: AnchorProps) {
|
|
25
|
+
const { links, scrollRef, activeIndex, smooth = true, style, ...rest } = props;
|
|
26
|
+
const theme = useTheme();
|
|
27
|
+
return (
|
|
28
|
+
<View accessibilityRole="menu" style={style} {...rest}>
|
|
29
|
+
{links.map((link, idx) => {
|
|
30
|
+
const isActive = activeIndex === idx;
|
|
31
|
+
return (
|
|
32
|
+
<Pressable
|
|
33
|
+
key={idx}
|
|
34
|
+
accessibilityRole="link"
|
|
35
|
+
accessibilityState={{ selected: isActive }}
|
|
36
|
+
onPress={() => scrollRef.current?.scrollTo({ y: link.offset, animated: smooth })}
|
|
37
|
+
style={{
|
|
38
|
+
paddingVertical: 6,
|
|
39
|
+
paddingLeft: 12,
|
|
40
|
+
borderLeftWidth: 2,
|
|
41
|
+
borderLeftColor: isActive ? theme.colors.intent.primary.solid : theme.colors.border,
|
|
42
|
+
backgroundColor: isActive ? theme.colors.intent.primary.subtle : 'transparent',
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<Text
|
|
46
|
+
style={{
|
|
47
|
+
fontSize: 13,
|
|
48
|
+
color: isActive ? theme.colors.intent.primary.fg : theme.colors.fgMuted,
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{link.label}
|
|
52
|
+
</Text>
|
|
53
|
+
</Pressable>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { FlatList, Pressable, TextInput, View, Text, type ViewProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
|
|
5
|
+
export interface AutoCompleteOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AutoCompleteProps extends Omit<ViewProps, 'children'> {
|
|
11
|
+
value?: string;
|
|
12
|
+
defaultValue?: string;
|
|
13
|
+
onChangeValue?: (value: string) => void;
|
|
14
|
+
options?: AutoCompleteOption[];
|
|
15
|
+
loadOptions?: (query: string) => Promise<AutoCompleteOption[]>;
|
|
16
|
+
onSelectOption?: (opt: AutoCompleteOption) => void;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
isDisabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AutoComplete(props: AutoCompleteProps) {
|
|
22
|
+
const {
|
|
23
|
+
value: valueProp,
|
|
24
|
+
defaultValue = '',
|
|
25
|
+
onChangeValue,
|
|
26
|
+
options = [],
|
|
27
|
+
loadOptions,
|
|
28
|
+
onSelectOption,
|
|
29
|
+
placeholder,
|
|
30
|
+
isDisabled,
|
|
31
|
+
style,
|
|
32
|
+
...rest
|
|
33
|
+
} = props;
|
|
34
|
+
const theme = useTheme();
|
|
35
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
36
|
+
const query = valueProp ?? internal;
|
|
37
|
+
const [isOpen, setOpen] = useState(false);
|
|
38
|
+
const [asyncOptions, setAsyncOptions] = useState<AutoCompleteOption[]>([]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!loadOptions) return;
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
loadOptions(query).then((opts) => {
|
|
44
|
+
if (!cancelled) setAsyncOptions(opts);
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
cancelled = true;
|
|
48
|
+
};
|
|
49
|
+
}, [query, loadOptions]);
|
|
50
|
+
|
|
51
|
+
const filtered = useMemo(() => {
|
|
52
|
+
const source = loadOptions ? asyncOptions : options;
|
|
53
|
+
if (!query) return source;
|
|
54
|
+
return source.filter((o) => o.value.toLowerCase().includes(query.toLowerCase()));
|
|
55
|
+
}, [asyncOptions, options, loadOptions, query]);
|
|
56
|
+
|
|
57
|
+
const setQuery = (next: string) => {
|
|
58
|
+
if (valueProp === undefined) setInternal(next);
|
|
59
|
+
onChangeValue?.(next);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View style={style} {...rest}>
|
|
64
|
+
<TextInput
|
|
65
|
+
value={query}
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
placeholderTextColor={theme.colors.fgMuted}
|
|
68
|
+
editable={!isDisabled}
|
|
69
|
+
onChangeText={(t) => {
|
|
70
|
+
setQuery(t);
|
|
71
|
+
setOpen(true);
|
|
72
|
+
}}
|
|
73
|
+
onFocus={() => setOpen(true)}
|
|
74
|
+
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
75
|
+
style={{
|
|
76
|
+
paddingHorizontal: 12,
|
|
77
|
+
paddingVertical: 8,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: theme.colors.border,
|
|
80
|
+
borderRadius: Number(theme.radii.md),
|
|
81
|
+
backgroundColor: isDisabled ? theme.colors.surface : theme.colors.surfaceElevated,
|
|
82
|
+
color: theme.colors.fg,
|
|
83
|
+
fontSize: 14,
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
{isOpen && filtered.length > 0 ? (
|
|
87
|
+
<View
|
|
88
|
+
style={{
|
|
89
|
+
marginTop: 4,
|
|
90
|
+
borderRadius: Number(theme.radii.md),
|
|
91
|
+
borderWidth: 1,
|
|
92
|
+
borderColor: theme.colors.border,
|
|
93
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
94
|
+
maxHeight: 200,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<FlatList
|
|
98
|
+
data={filtered}
|
|
99
|
+
keyExtractor={(o) => o.value}
|
|
100
|
+
keyboardShouldPersistTaps="handled"
|
|
101
|
+
renderItem={({ item }) => (
|
|
102
|
+
<Pressable
|
|
103
|
+
onPress={() => {
|
|
104
|
+
setQuery(item.value);
|
|
105
|
+
onSelectOption?.(item);
|
|
106
|
+
setOpen(false);
|
|
107
|
+
}}
|
|
108
|
+
style={({ pressed }) => ({
|
|
109
|
+
paddingHorizontal: 12,
|
|
110
|
+
paddingVertical: 10,
|
|
111
|
+
backgroundColor: pressed ? theme.colors.intent.neutral.subtle : 'transparent',
|
|
112
|
+
})}
|
|
113
|
+
>
|
|
114
|
+
<Text style={{ color: theme.colors.fg }}>{item.label ?? item.value}</Text>
|
|
115
|
+
</Pressable>
|
|
116
|
+
)}
|
|
117
|
+
/>
|
|
118
|
+
</View>
|
|
119
|
+
) : null}
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
package/src/Avatar.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { forwardRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Image, Text, View, type ImageSourcePropType, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraSize } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
export interface AvatarProps {
|
|
7
|
+
src?: string | ImageSourcePropType;
|
|
8
|
+
alt?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
size?: ElvoraSize;
|
|
11
|
+
shape?: 'circle' | 'square';
|
|
12
|
+
fallback?: ReactNode;
|
|
13
|
+
style?: StyleProp<ViewStyle>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeMap: Record<ElvoraSize, number> = { xs: 20, sm: 28, md: 36, lg: 48, xl: 64 };
|
|
17
|
+
|
|
18
|
+
export const Avatar = forwardRef<View, AvatarProps>(function Avatar(props, ref) {
|
|
19
|
+
const { src, alt, name, size = 'md', shape = 'circle', fallback, style } = props;
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const [imgFailed, setImgFailed] = useState(false);
|
|
22
|
+
const px = sizeMap[size];
|
|
23
|
+
const initials = (name ?? '')
|
|
24
|
+
.split(' ')
|
|
25
|
+
.map((p) => p[0])
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.slice(0, 2)
|
|
28
|
+
.join('')
|
|
29
|
+
.toUpperCase();
|
|
30
|
+
|
|
31
|
+
const container: ViewStyle = {
|
|
32
|
+
width: px,
|
|
33
|
+
height: px,
|
|
34
|
+
borderRadius: shape === 'circle' ? px / 2 : Number(theme.radii.md),
|
|
35
|
+
backgroundColor: theme.colors.intent.neutral.subtle,
|
|
36
|
+
alignItems: 'center',
|
|
37
|
+
justifyContent: 'center',
|
|
38
|
+
overflow: 'hidden',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const showImage = src && !imgFailed;
|
|
42
|
+
const source: ImageSourcePropType | undefined =
|
|
43
|
+
typeof src === 'string' ? { uri: src } : (src as ImageSourcePropType | undefined);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<View ref={ref} accessibilityLabel={alt ?? name} style={[container, style]}>
|
|
47
|
+
{showImage && source ? (
|
|
48
|
+
<Image source={source} onError={() => setImgFailed(true)} style={{ width: '100%', height: '100%' }} />
|
|
49
|
+
) : initials ? (
|
|
50
|
+
<Text style={{ color: theme.colors.intent.neutral.fg, fontWeight: '600', fontSize: Math.max(10, Math.floor(px * 0.4)) }}>
|
|
51
|
+
{initials}
|
|
52
|
+
</Text>
|
|
53
|
+
) : (
|
|
54
|
+
fallback ?? <Text style={{ color: theme.colors.intent.neutral.fg }}>?</Text>
|
|
55
|
+
)}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
});
|
package/src/BackTop.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from 'react';
|
|
2
|
+
import { Animated, Pressable, type ScrollView } from 'react-native';
|
|
3
|
+
import { useTheme } from './ElvoraProvider';
|
|
4
|
+
import { Icon } from './Icon';
|
|
5
|
+
|
|
6
|
+
export interface BackTopProps {
|
|
7
|
+
/** Ref to the ScrollView this button scrolls to top. */
|
|
8
|
+
scrollRef: RefObject<ScrollView>;
|
|
9
|
+
/** Current Y offset of the parent ScrollView (caller wires up `onScroll`). */
|
|
10
|
+
scrollY: number;
|
|
11
|
+
/** Show after the user has scrolled past this offset (default 400). */
|
|
12
|
+
visibilityHeight?: number;
|
|
13
|
+
bottom?: number;
|
|
14
|
+
right?: number;
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* BackTop — floating "scroll to top" button for RN. Caller passes a ref to the
|
|
20
|
+
* parent ScrollView plus the current scroll offset (e.g. from `onScroll` event).
|
|
21
|
+
*/
|
|
22
|
+
export function BackTop(props: BackTopProps) {
|
|
23
|
+
const { scrollRef, scrollY, visibilityHeight = 400, bottom = 24, right = 24, label = 'Back to top' } = props;
|
|
24
|
+
const theme = useTheme();
|
|
25
|
+
const [visible, setVisible] = useState(false);
|
|
26
|
+
const opacity = useState(new Animated.Value(0))[0];
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setVisible(scrollY > visibilityHeight);
|
|
30
|
+
}, [scrollY, visibilityHeight]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
Animated.timing(opacity, { toValue: visible ? 1 : 0, duration: 200, useNativeDriver: true }).start();
|
|
34
|
+
}, [visible, opacity]);
|
|
35
|
+
|
|
36
|
+
if (!visible) return null;
|
|
37
|
+
return (
|
|
38
|
+
<Animated.View
|
|
39
|
+
pointerEvents={visible ? 'auto' : 'none'}
|
|
40
|
+
style={{
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
bottom,
|
|
43
|
+
right,
|
|
44
|
+
opacity,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<Pressable
|
|
48
|
+
accessibilityRole="button"
|
|
49
|
+
accessibilityLabel={label}
|
|
50
|
+
onPress={() => scrollRef.current?.scrollTo({ y: 0, animated: true })}
|
|
51
|
+
style={{
|
|
52
|
+
width: 44,
|
|
53
|
+
height: 44,
|
|
54
|
+
borderRadius: 22,
|
|
55
|
+
borderWidth: 1,
|
|
56
|
+
borderColor: theme.colors.border,
|
|
57
|
+
backgroundColor: theme.colors.surfaceElevated,
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'center',
|
|
60
|
+
shadowColor: '#000',
|
|
61
|
+
shadowOpacity: 0.15,
|
|
62
|
+
shadowRadius: 8,
|
|
63
|
+
shadowOffset: { width: 0, height: 4 },
|
|
64
|
+
elevation: 4,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<Icon name="chevronUp" size={18} color={theme.colors.fg} />
|
|
68
|
+
</Pressable>
|
|
69
|
+
</Animated.View>
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/Backdrop.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Modal, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface BackdropProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose?: () => void;
|
|
7
|
+
tint?: string;
|
|
8
|
+
invisible?: boolean;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Backdrop(props: BackdropProps) {
|
|
13
|
+
const { open, onClose, tint, invisible, children } = props;
|
|
14
|
+
if (!open) return null;
|
|
15
|
+
return (
|
|
16
|
+
<Modal transparent visible animationType="fade" onRequestClose={onClose}>
|
|
17
|
+
<Pressable
|
|
18
|
+
accessibilityRole="button"
|
|
19
|
+
accessibilityLabel="Close"
|
|
20
|
+
onPress={onClose}
|
|
21
|
+
style={{
|
|
22
|
+
flex: 1,
|
|
23
|
+
backgroundColor: invisible ? 'transparent' : tint ?? 'rgba(0,0,0,0.55)',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
justifyContent: 'center',
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<View>{children}</View>
|
|
29
|
+
</Pressable>
|
|
30
|
+
</Modal>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/Badge.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import { Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { ElvoraSize, ElvoraStatus, ElvoraTone } from '@elvora/core';
|
|
4
|
+
import { useTheme } from './ElvoraProvider';
|
|
5
|
+
|
|
6
|
+
export interface BadgeProps {
|
|
7
|
+
status?: ElvoraStatus;
|
|
8
|
+
size?: Exclude<ElvoraSize, 'xl'>;
|
|
9
|
+
tone?: ElvoraTone;
|
|
10
|
+
isDot?: boolean;
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sizeMap = {
|
|
16
|
+
xs: { padX: 4, padY: 1, font: 10, dotSize: 6 },
|
|
17
|
+
sm: { padX: 6, padY: 2, font: 11, dotSize: 8 },
|
|
18
|
+
md: { padX: 8, padY: 2, font: 12, dotSize: 10 },
|
|
19
|
+
lg: { padX: 10, padY: 4, font: 13, dotSize: 12 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const statusIntent = {
|
|
23
|
+
neutral: 'neutral',
|
|
24
|
+
info: 'info',
|
|
25
|
+
success: 'success',
|
|
26
|
+
warning: 'warning',
|
|
27
|
+
error: 'danger',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const Badge = forwardRef<View, BadgeProps>(function Badge(props, ref) {
|
|
31
|
+
const { status = 'neutral', size = 'sm', tone = 'subtle', isDot = false, children, style } = props;
|
|
32
|
+
const theme = useTheme();
|
|
33
|
+
const intent = theme.colors.intent[statusIntent[status]];
|
|
34
|
+
const dims = sizeMap[size];
|
|
35
|
+
|
|
36
|
+
let bg = intent.subtle;
|
|
37
|
+
let fg = intent.fg;
|
|
38
|
+
let border = 'transparent';
|
|
39
|
+
if (tone === 'solid') {
|
|
40
|
+
bg = intent.solid;
|
|
41
|
+
fg = intent.solidFg;
|
|
42
|
+
} else if (tone === 'outline') {
|
|
43
|
+
bg = 'transparent';
|
|
44
|
+
fg = intent.fg;
|
|
45
|
+
border = intent.border;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isDot) {
|
|
49
|
+
return (
|
|
50
|
+
<View
|
|
51
|
+
ref={ref}
|
|
52
|
+
accessibilityRole="image"
|
|
53
|
+
style={[
|
|
54
|
+
{
|
|
55
|
+
width: dims.dotSize,
|
|
56
|
+
height: dims.dotSize,
|
|
57
|
+
borderRadius: dims.dotSize / 2,
|
|
58
|
+
backgroundColor: bg,
|
|
59
|
+
borderWidth: tone === 'outline' ? 1 : 0,
|
|
60
|
+
borderColor: border,
|
|
61
|
+
},
|
|
62
|
+
style,
|
|
63
|
+
]}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View
|
|
70
|
+
ref={ref}
|
|
71
|
+
style={[
|
|
72
|
+
{
|
|
73
|
+
paddingHorizontal: dims.padX,
|
|
74
|
+
paddingVertical: dims.padY,
|
|
75
|
+
borderRadius: Number(theme.radii.md),
|
|
76
|
+
backgroundColor: bg,
|
|
77
|
+
borderWidth: tone === 'outline' ? 1 : 0,
|
|
78
|
+
borderColor: border,
|
|
79
|
+
alignSelf: 'flex-start',
|
|
80
|
+
},
|
|
81
|
+
style,
|
|
82
|
+
]}
|
|
83
|
+
>
|
|
84
|
+
<Text style={{ color: fg, fontSize: dims.font, fontWeight: '500' }}>{children}</Text>
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
});
|