@oneplatformdev/react-native-uikit 1.1.1 → 1.2.0

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 CHANGED
@@ -9,12 +9,17 @@ Reusable React Native theme, scaling, and UI components.
9
9
  Install with `npm install @oneplatformdev/react-native-uikit` and import ready-made components:
10
10
 
11
11
  ```tsx
12
- import { Button, Input, Typography, UIProvider } from '@oneplatformdev/react-native-uikit';
12
+ import { Avatar, Button, Checkbox, EmptyListView, Input, PhotoPreview, Switch, Typography, UIProvider } from '@oneplatformdev/react-native-uikit';
13
13
 
14
14
  <UIProvider>
15
15
  <Typography text="Hello" />
16
16
  <Input label="Name" value={name} onChangeText={setName} />
17
17
  <Button text="Continue" onPress={onContinue} />
18
+ <Switch value={notificationsEnabled} onValueChange={setNotificationsEnabled} accessibilityLabel="Notifications" />
19
+ <Checkbox checked={accepted} onValueChange={setAccepted} accessibilityLabel="Accept terms" />
20
+ <PhotoPreview source={{ uri: photoUri }} onPress={onOpenPhoto} accessibilityLabel="Open photo" />
21
+ <EmptyListView title="No items" description="Try again later" />
22
+ <Avatar source={{ uri: avatarUri }} name="Mykhailo Novikov" />
18
23
  </UIProvider>
19
24
  ```
20
25
 
@@ -26,15 +31,20 @@ Run from the consuming project root after declaring `@oneplatformdev/react-nativ
26
31
 
27
32
  ```sh
28
33
  npx @oneplatformdev/react-native-uikit add typography
34
+ npx @oneplatformdev/react-native-uikit add avatar
29
35
  npx @oneplatformdev/react-native-uikit add button
36
+ npx @oneplatformdev/react-native-uikit add checkbox
30
37
  npx @oneplatformdev/react-native-uikit add input
31
38
  npx @oneplatformdev/react-native-uikit add screen-container
39
+ npx @oneplatformdev/react-native-uikit add switch
32
40
  npx @oneplatformdev/react-native-uikit add custom-alert
41
+ npx @oneplatformdev/react-native-uikit add empty-list-view
33
42
  npx @oneplatformdev/react-native-uikit add bottom-modal
34
43
  npx @oneplatformdev/react-native-uikit add connection-container
35
44
  npx @oneplatformdev/react-native-uikit add header-with-back-button
36
45
  npx @oneplatformdev/react-native-uikit add loader
37
46
  npx @oneplatformdev/react-native-uikit add logger
47
+ npx @oneplatformdev/react-native-uikit add photo-preview
38
48
  npx @oneplatformdev/react-native-uikit add button --dir src/UIKit
39
49
  ```
40
50
 
@@ -295,6 +305,28 @@ Input derives its native accessibility label from `label` only when the caller o
295
305
 
296
306
  Owned/local source installation for Input: **available** with `npx @oneplatformdev/react-native-uikit add input`.
297
307
 
308
+ ## Switch and Checkbox
309
+
310
+ Both are controlled: pass `value`/`onValueChange` to `Switch` or `checked`/`onValueChange` to `Checkbox`. They use theme colors by default, accept accessibility labels and hints, and keep a usable press target even with small visual dimensions.
311
+
312
+ `Switch` defaults to a 40×24 track with a 20-unit thumb. Override `width`, `height`, `thumbSize`, track/thumb colors, or provide a custom `thumb` node. The consumer owns the custom node's appearance. The thumb animates when `value` changes.
313
+
314
+ `Checkbox` defaults to a 22-unit slightly rounded square with a built-in checkmark. Override `size`, state colors, `borderWidth`, and `iconSize`/`iconColor`. Use `shape="circle"` for a radius derived from its size, or `shape="square"` with `borderRadius` (including `0`) for custom corners. Supply an `icon` node to replace the built-in indicator; the consumer owns that node's size and color.
315
+
316
+ Owned source is available through `npx @oneplatformdev/react-native-uikit add switch` and `npx @oneplatformdev/react-native-uikit add checkbox`. Both are standalone components; Switch uses the existing `react-native-reanimated` peer.
317
+
318
+ ## PhotoPreview and EmptyListView
319
+
320
+ `PhotoPreview` displays a supplied React Native image source. It defaults to a 120-unit square with the theme's medium radius. Set `width` and/or `height` for a different size; `aspectRatio` derives the missing dimension (width wins if both dimensions and a ratio are supplied). `style` is applied after the base container geometry, and `imageStyle` after the fill-image style. Supply `onPress` to make the image a button, `onRemovePress` for a separate remove control, or `overlay` for caller-owned content. A built-in × is used unless `removeIcon` is provided; supply localized accessibility labels for actions. The component does not open a modal or own image state.
321
+
322
+ `EmptyListView` centers optional `visual`, `title`, and `description` in available list space. Strings and numbers use themed Typography (`title` and `body` by default); custom React nodes render as supplied. `titleStyle` and `descriptionStyle` apply to built-in text, while custom nodes own their styling. A supplied `action` replaces the built-in Button; otherwise the Button appears only when both `actionLabel` and `onActionPress` are given. The optional `accessibilityLabel` labels the visual without grouping the readable text or action.
323
+
324
+ Owned source is available with `npx @oneplatformdev/react-native-uikit add photo-preview` and `npx @oneplatformdev/react-native-uikit add empty-list-view`. The latter also copies local Typography and Button dependencies.
325
+
326
+ ## Avatar
327
+
328
+ `Avatar` accepts a React Native image `source` and defaults to a 40-unit circle. Override `size`, use `shape="square"` with `borderRadius` (including `0`), or customize `backgroundColor` and `textColor`. Fallback order is image → custom `fallback` → `fallbackText` → up to two initials from `name` → a dependency-free silhouette. If an image fails, the fallback appears; changing `source` allows the new image to load. `badge` is positioned at the edge without being clipped. Pass `onPress` for a labeled button; otherwise the avatar exposes image semantics. For a button, `accessibilityLabel` names the action and `imageAccessibilityLabel` is ignored; without `onPress`, `imageAccessibilityLabel` overrides the image label. `size` and `shape` remain authoritative over geometry in `style`/`imageStyle`. Provide localized accessibility text where needed. Owned source: `npx @oneplatformdev/react-native-uikit add avatar` (also installs local Typography).
329
+
298
330
  ## ScreenContainer, CustomAlert, and BottomModal
299
331
 
300
332
  `ScreenContainer` provides a theme-backed safe-area layout, optional scrolling, and optional keyboard-aware scrolling or avoidance. Pass `gradient={{ colors, locations, start, end }}` to render a caller-configured background across the entire screen, behind the safe-area content; omit it for the solid theme background. Gradient coordinates are normalized and are not scaled. Pass ordinary ScrollView props when scrolling is enabled; use `containerStyle` and `contentContainerStyle` for layout overrides.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneplatformdev/react-native-uikit",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "bin": {
@@ -11,10 +11,22 @@
11
11
  "../../../utils": "@oneplatformdev/react-native-uikit"
12
12
  }
13
13
  },
14
+ "avatar": {
15
+ "folder": "Avatar",
16
+ "files": ["ui/Avatar.tsx", "presenters/useAvatar.ts", "ui/styles.ts", "types/types.ts", "index.ts"],
17
+ "componentDependencies": ["typography"],
18
+ "foundations": ["useUITheme", "useScaling", "Typography", "UIColors", "UIRadius", "ScalingFunctions"],
19
+ "dependencies": ["react", "react-native", "@oneplatformdev/react-native-uikit"],
20
+ "rewrites": {
21
+ "../../../theme": "@oneplatformdev/react-native-uikit",
22
+ "../../../utils": "@oneplatformdev/react-native-uikit"
23
+ }
24
+ },
14
25
  "button": {
15
26
  "folder": "Button",
16
27
  "files": ["ui/Button.tsx", "presenters/useButton.ts", "ui/styles.ts", "types/types.ts", "index.ts"],
17
28
  "componentDependencies": ["typography"],
29
+ "publicExports": { "Button": "ui/Button.tsx", "ButtonProps": "types/types.ts" },
18
30
  "foundations": ["useUITheme", "useScaling", "Typography", "TypographyProps", "UIColors", "UIButtonSizeTheme", "UIButtonVariantTheme", "ScalingFunctions"],
19
31
  "dependencies": ["react", "react-native", "@oneplatformdev/react-native-uikit"],
20
32
  "rewrites": {
@@ -22,6 +34,50 @@
22
34
  "../../../utils": "@oneplatformdev/react-native-uikit"
23
35
  }
24
36
  },
37
+ "checkbox": {
38
+ "folder": "Checkbox",
39
+ "files": ["ui/Checkbox.tsx", "ui/styles.ts", "types/types.ts", "index.ts"],
40
+ "componentDependencies": [],
41
+ "foundations": ["useUITheme", "useScaling", "UIColors", "UIRadius", "ScalingFunctions"],
42
+ "dependencies": ["react", "react-native", "@oneplatformdev/react-native-uikit"],
43
+ "rewrites": {
44
+ "../../../theme": "@oneplatformdev/react-native-uikit",
45
+ "../../../utils": "@oneplatformdev/react-native-uikit"
46
+ }
47
+ },
48
+ "switch": {
49
+ "folder": "Switch",
50
+ "files": ["ui/Switch.tsx", "presenters/useSwitch.ts", "ui/styles.ts", "types/types.ts", "index.ts"],
51
+ "componentDependencies": [],
52
+ "foundations": ["useUITheme", "useScaling", "ScalingFunctions"],
53
+ "dependencies": ["react", "react-native", "react-native-reanimated", "@oneplatformdev/react-native-uikit"],
54
+ "rewrites": {
55
+ "../../../theme": "@oneplatformdev/react-native-uikit",
56
+ "../../../utils": "@oneplatformdev/react-native-uikit"
57
+ }
58
+ },
59
+ "photo-preview": {
60
+ "folder": "PhotoPreview",
61
+ "files": ["ui/PhotoPreview.tsx", "ui/styles.ts", "types/types.ts", "index.ts"],
62
+ "componentDependencies": [],
63
+ "foundations": ["useUITheme", "useScaling", "UIColors", "UIRadius", "UISpacing", "ScalingFunctions"],
64
+ "dependencies": ["react", "react-native", "@oneplatformdev/react-native-uikit"],
65
+ "rewrites": {
66
+ "../../../theme": "@oneplatformdev/react-native-uikit",
67
+ "../../../utils": "@oneplatformdev/react-native-uikit"
68
+ }
69
+ },
70
+ "empty-list-view": {
71
+ "folder": "EmptyListView",
72
+ "files": ["ui/EmptyListView.tsx", "ui/styles.ts", "types/types.ts", "index.ts"],
73
+ "componentDependencies": ["typography", "button"],
74
+ "foundations": ["useUITheme", "useScaling", "Typography", "Button", "TypographyProps", "UISpacing", "ScalingFunctions"],
75
+ "dependencies": ["react", "react-native", "@oneplatformdev/react-native-uikit"],
76
+ "rewrites": {
77
+ "../../../theme": "@oneplatformdev/react-native-uikit",
78
+ "../../../utils": "@oneplatformdev/react-native-uikit"
79
+ }
80
+ },
25
81
  "input": {
26
82
  "folder": "Input",
27
83
  "files": ["ui/Input.tsx", "presenters/useInput.ts", "ui/styles.ts", "types/types.ts", "index.ts"],
@@ -0,0 +1,2 @@
1
+ export { Avatar } from './ui/Avatar';
2
+ export type { AvatarProps } from './types/types';
@@ -0,0 +1,33 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { ImageSourcePropType } from 'react-native';
3
+
4
+ const getSourceKey = (source?: ImageSourcePropType): string | null => {
5
+ if (typeof source === 'number') return Number.isFinite(source) ? `asset:${source}` : null;
6
+ if (Array.isArray(source)) {
7
+ return source.some(item => typeof item.uri === 'string' && item.uri.trim()) ? JSON.stringify(source) : null;
8
+ }
9
+ return source && 'uri' in source && typeof source.uri === 'string' && source.uri.trim()
10
+ ? JSON.stringify(source) : null;
11
+ };
12
+
13
+ export const getInitials = (name?: string): string => name?.trim().split(/\s+/).slice(0, 2)
14
+ .map(word => Array.from(word)[0]?.toUpperCase() ?? '').join('') ?? '';
15
+
16
+ export const useAvatar = (source?: ImageSourcePropType, name?: string) => {
17
+ const sourceKey = getSourceKey(source);
18
+ const [failedSourceKey, setFailedSourceKey] = useState<string | null>(null);
19
+
20
+ useEffect(() => {
21
+ setFailedSourceKey(null);
22
+ }, [sourceKey]);
23
+
24
+ const onImageError = useCallback(() => {
25
+ setFailedSourceKey(sourceKey);
26
+ }, [sourceKey]);
27
+
28
+ return {
29
+ showImage: sourceKey !== null && failedSourceKey !== sourceKey,
30
+ onImageError,
31
+ initials: getInitials(name),
32
+ };
33
+ };
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ImageSourcePropType, ImageStyle, StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface AvatarProps {
5
+ source?: ImageSourcePropType;
6
+ name?: string;
7
+ fallbackText?: string;
8
+ fallback?: ReactNode;
9
+ size?: number;
10
+ shape?: 'circle' | 'square';
11
+ borderRadius?: number;
12
+ backgroundColor?: string;
13
+ textColor?: string;
14
+ imageStyle?: StyleProp<ImageStyle>;
15
+ style?: StyleProp<ViewStyle>;
16
+ badge?: ReactNode;
17
+ onPress?: () => void;
18
+ disabled?: boolean;
19
+ accessibilityLabel?: string;
20
+ accessibilityHint?: string;
21
+ imageAccessibilityLabel?: string;
22
+ }
@@ -0,0 +1,84 @@
1
+ import { useMemo } from 'react';
2
+ import { Image, Pressable, View } from 'react-native';
3
+ import { Typography } from '../../Typography';
4
+ import { useUITheme } from '../../../theme';
5
+ import { useScaling } from '../../../utils';
6
+ import { useAvatar } from '../presenters/useAvatar';
7
+ import { getStyles } from './styles';
8
+ import type { AvatarProps } from '../types/types';
9
+
10
+ export const Avatar = ({
11
+ source,
12
+ name,
13
+ fallbackText,
14
+ fallback,
15
+ size = 40,
16
+ shape = 'circle',
17
+ borderRadius,
18
+ backgroundColor,
19
+ textColor,
20
+ imageStyle,
21
+ style,
22
+ badge,
23
+ onPress,
24
+ disabled = false,
25
+ accessibilityLabel,
26
+ accessibilityHint,
27
+ imageAccessibilityLabel,
28
+ }: AvatarProps) => {
29
+ const { colors, radius } = useUITheme();
30
+ const scaling = useScaling();
31
+ const { styles, hitSlop, textSize, textLineHeight } = useMemo(
32
+ () => getStyles(colors, radius, scaling, size, shape, borderRadius, backgroundColor, textColor),
33
+ [colors, radius, scaling, size, shape, borderRadius, backgroundColor, textColor],
34
+ );
35
+ const { showImage, onImageError, initials } = useAvatar(source, name);
36
+ const text = fallbackText?.trim() || initials;
37
+ const fallbackLabel = name?.trim() || fallbackText?.trim() || 'Avatar';
38
+ const label = onPress
39
+ ? accessibilityLabel ?? fallbackLabel
40
+ : imageAccessibilityLabel ?? accessibilityLabel ?? fallbackLabel;
41
+ const content = showImage ? (
42
+ <Image source={source} onError={onImageError} accessible={false} style={[imageStyle, styles.image]} />
43
+ ) : fallback != null && typeof fallback !== 'boolean' ? (
44
+ typeof fallback === 'string' || typeof fallback === 'number'
45
+ ? <Typography text={fallback} size={textSize} lineHeight={textLineHeight} style={styles.text} accessible={false} />
46
+ : fallback
47
+ ) : text ? (
48
+ <Typography text={text} size={textSize} lineHeight={textLineHeight} style={styles.text} accessible={false} numberOfLines={1} />
49
+ ) : (
50
+ <View style={styles.generic} accessible={false}>
51
+ <View style={styles.genericHead} />
52
+ <View style={styles.genericShoulders} />
53
+ </View>
54
+ );
55
+
56
+ return (
57
+ <View style={[style, styles.wrapper]}>
58
+ {onPress ? (
59
+ <Pressable
60
+ accessibilityRole="button"
61
+ accessibilityLabel={label}
62
+ accessibilityHint={accessibilityHint}
63
+ accessibilityState={{ disabled }}
64
+ disabled={disabled}
65
+ hitSlop={hitSlop}
66
+ onPress={onPress}
67
+ style={styles.surface}
68
+ >
69
+ {content}
70
+ </Pressable>
71
+ ) : (
72
+ <View accessible accessibilityRole="image" accessibilityLabel={label} style={styles.surface}>
73
+ {content}
74
+ </View>
75
+ )}
76
+ {badge != null && typeof badge !== 'boolean' ? (
77
+ <View pointerEvents="box-none" style={styles.badge}>
78
+ {typeof badge === 'string' || typeof badge === 'number'
79
+ ? <Typography text={badge} variant="caption" /> : badge}
80
+ </View>
81
+ ) : null}
82
+ </View>
83
+ );
84
+ };
@@ -0,0 +1,72 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import type { UIColors, UIRadius } from '../../../theme';
3
+ import type { ScalingFunctions } from '../../../utils';
4
+ import type { AvatarProps } from '../types/types';
5
+
6
+ export const getStyles = (
7
+ colors: UIColors,
8
+ radius: UIRadius,
9
+ scaling: ScalingFunctions,
10
+ size: number,
11
+ shape: AvatarProps['shape'],
12
+ borderRadius: AvatarProps['borderRadius'],
13
+ backgroundColor: AvatarProps['backgroundColor'],
14
+ textColor: AvatarProps['textColor'],
15
+ ) => {
16
+ const designSize = Number.isFinite(size) && size > 0 ? size : 40;
17
+ const scaledSize = scaling.scaleHorizontal(designSize);
18
+ const designRadius = borderRadius !== undefined && Number.isFinite(borderRadius)
19
+ ? Math.max(0, borderRadius) : radius.sm;
20
+ const foreground = textColor ?? colors.textMuted;
21
+
22
+ const styles = StyleSheet.create({
23
+ wrapper: {
24
+ width: scaledSize,
25
+ height: scaledSize,
26
+ overflow: 'visible',
27
+ },
28
+ surface: {
29
+ width: scaledSize,
30
+ height: scaledSize,
31
+ borderRadius: shape === 'square' ? scaling.scaleHorizontal(designRadius) : scaledSize / 2,
32
+ overflow: 'hidden',
33
+ backgroundColor: backgroundColor ?? colors.surface,
34
+ alignItems: 'center',
35
+ justifyContent: 'center',
36
+ },
37
+ image: {
38
+ ...StyleSheet.absoluteFill,
39
+ },
40
+ text: {
41
+ color: foreground,
42
+ textAlign: 'center',
43
+ maxWidth: '100%',
44
+ },
45
+ generic: {
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ },
49
+ genericHead: {
50
+ width: scaledSize * 0.23,
51
+ height: scaledSize * 0.23,
52
+ borderRadius: scaledSize * 0.115,
53
+ backgroundColor: foreground,
54
+ },
55
+ genericShoulders: {
56
+ width: scaledSize * 0.48,
57
+ height: scaledSize * 0.22,
58
+ marginTop: scaledSize * 0.04,
59
+ borderTopLeftRadius: scaledSize * 0.24,
60
+ borderTopRightRadius: scaledSize * 0.24,
61
+ backgroundColor: foreground,
62
+ },
63
+ badge: {
64
+ position: 'absolute',
65
+ right: -scaledSize * 0.08,
66
+ bottom: -scaledSize * 0.08,
67
+ },
68
+ });
69
+ const hitSlop = scaling.scaleHorizontal(Math.max(8, (44 - designSize) / 2));
70
+
71
+ return { styles, hitSlop, textSize: designSize * 0.34, textLineHeight: designSize * 0.42 };
72
+ };
@@ -1,8 +1,2 @@
1
- export { Button } from './ui/Button';
2
- export type {
3
- ButtonLabelProps,
4
- ButtonLoadingAppearance,
5
- ButtonProps,
6
- ButtonSize,
7
- ButtonVariant,
8
- } from './types/types';
1
+ export * from './ui/Button';
2
+ export * from './types/types';
@@ -0,0 +1,2 @@
1
+ export { Checkbox } from './ui/Checkbox';
2
+ export type { CheckboxProps } from './types/types';
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface CheckboxProps {
5
+ checked: boolean;
6
+ onValueChange?: (checked: boolean) => void;
7
+ disabled?: boolean;
8
+ size?: number;
9
+ shape?: 'square' | 'circle';
10
+ borderRadius?: number;
11
+ borderWidth?: number;
12
+ checkedBackgroundColor?: string;
13
+ uncheckedBackgroundColor?: string;
14
+ checkedBorderColor?: string;
15
+ uncheckedBorderColor?: string;
16
+ disabledBackgroundColor?: string;
17
+ disabledBorderColor?: string;
18
+ icon?: ReactNode;
19
+ iconSize?: number;
20
+ iconColor?: string;
21
+ accessibilityLabel?: string;
22
+ accessibilityHint?: string;
23
+ style?: StyleProp<ViewStyle>;
24
+ }
@@ -0,0 +1,72 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { useUITheme } from '../../../theme';
4
+ import { useScaling } from '../../../utils';
5
+ import { getStyles } from './styles';
6
+ import type { CheckboxProps } from '../types/types';
7
+
8
+ export const Checkbox = ({
9
+ checked,
10
+ onValueChange,
11
+ disabled = false,
12
+ size = 22,
13
+ shape = 'square',
14
+ borderRadius,
15
+ borderWidth,
16
+ checkedBackgroundColor,
17
+ uncheckedBackgroundColor,
18
+ checkedBorderColor,
19
+ uncheckedBorderColor,
20
+ disabledBackgroundColor,
21
+ disabledBorderColor,
22
+ icon,
23
+ iconSize,
24
+ iconColor,
25
+ accessibilityLabel,
26
+ accessibilityHint,
27
+ style,
28
+ }: CheckboxProps) => {
29
+ const { colors, radius } = useUITheme();
30
+ const scaling = useScaling();
31
+ const { styles, hitSlop } = useMemo(() => getStyles(colors, radius, scaling, checked, disabled, {
32
+ size,
33
+ shape,
34
+ borderRadius,
35
+ borderWidth,
36
+ checkedBackgroundColor,
37
+ uncheckedBackgroundColor,
38
+ checkedBorderColor,
39
+ uncheckedBorderColor,
40
+ disabledBackgroundColor,
41
+ disabledBorderColor,
42
+ iconSize,
43
+ iconColor,
44
+ }), [
45
+ colors, radius, scaling, checked, disabled, size, shape, borderRadius, borderWidth,
46
+ checkedBackgroundColor, uncheckedBackgroundColor, checkedBorderColor, uncheckedBorderColor,
47
+ disabledBackgroundColor, disabledBorderColor, iconSize, iconColor,
48
+ ]);
49
+ const onPress = useCallback(() => {
50
+ if (!disabled) onValueChange?.(!checked);
51
+ }, [checked, disabled, onValueChange]);
52
+
53
+ return (
54
+ <Pressable
55
+ accessibilityRole="checkbox"
56
+ accessibilityLabel={accessibilityLabel}
57
+ accessibilityHint={accessibilityHint}
58
+ accessibilityState={{ checked, disabled }}
59
+ disabled={disabled}
60
+ hitSlop={hitSlop}
61
+ onPress={onPress}
62
+ style={[styles.container, style]}
63
+ >
64
+ {checked ? (
65
+ <View pointerEvents="none">
66
+ {icon === undefined ? <View style={styles.indicator} />
67
+ : typeof icon === 'string' || typeof icon === 'number' ? <Text>{icon}</Text> : icon}
68
+ </View>
69
+ ) : null}
70
+ </Pressable>
71
+ );
72
+ };
@@ -0,0 +1,67 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import type { UIColors, UIRadius } from '../../../theme';
3
+ import type { ScalingFunctions } from '../../../utils';
4
+ import type { CheckboxProps } from '../types/types';
5
+
6
+ type CheckboxVisualProps = Pick<CheckboxProps,
7
+ | 'size' | 'shape' | 'borderRadius' | 'borderWidth' | 'checkedBackgroundColor' | 'uncheckedBackgroundColor'
8
+ | 'checkedBorderColor' | 'uncheckedBorderColor' | 'disabledBackgroundColor' | 'disabledBorderColor'
9
+ | 'iconSize' | 'iconColor'>;
10
+
11
+ const positiveOr = (value: number | undefined, fallback: number) =>
12
+ value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;
13
+
14
+ export const getStyles = (
15
+ colors: UIColors,
16
+ radius: UIRadius,
17
+ scaling: ScalingFunctions,
18
+ checked: boolean,
19
+ disabled: boolean,
20
+ props: CheckboxVisualProps,
21
+ ) => {
22
+ const designSize = positiveOr(props.size, 22);
23
+ const designBorderWidth = Math.min(
24
+ props.borderWidth !== undefined && Number.isFinite(props.borderWidth) ? Math.max(0, props.borderWidth) : 2,
25
+ designSize / 4,
26
+ );
27
+ const designIconSize = Math.min(positiveOr(props.iconSize, 12), designSize - 2 * designBorderWidth);
28
+ const designRadius = props.borderRadius !== undefined && Number.isFinite(props.borderRadius)
29
+ ? Math.max(0, props.borderRadius) : radius.sm;
30
+ const size = scaling.scaleHorizontal(designSize);
31
+ const borderWidth = scaling.scaleHorizontal(designBorderWidth);
32
+ const iconSize = scaling.scaleHorizontal(designIconSize);
33
+ const backgroundColor = disabled
34
+ ? props.disabledBackgroundColor ?? colors.disabled
35
+ : checked ? props.checkedBackgroundColor ?? colors.primary : props.uncheckedBackgroundColor ?? colors.background;
36
+ const borderColor = disabled
37
+ ? props.disabledBorderColor ?? colors.disabled
38
+ : checked ? props.checkedBorderColor ?? colors.primary : props.uncheckedBorderColor ?? colors.border;
39
+ const indicatorColor = props.iconColor ?? (disabled ? colors.onDisabled : colors.onPrimary);
40
+
41
+ const styles = StyleSheet.create({
42
+ container: {
43
+ width: size,
44
+ height: size,
45
+ borderWidth,
46
+ borderRadius: props.shape === 'circle'
47
+ ? size / 2
48
+ : scaling.scaleHorizontal(designRadius),
49
+ backgroundColor,
50
+ borderColor,
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ flexShrink: 0,
54
+ },
55
+ indicator: {
56
+ width: iconSize * 0.55,
57
+ height: iconSize * 0.8,
58
+ borderRightWidth: scaling.scaleHorizontal(Math.min(2, designIconSize / 4)),
59
+ borderBottomWidth: scaling.scaleHorizontal(Math.min(2, designIconSize / 4)),
60
+ borderColor: indicatorColor,
61
+ transform: [{ rotate: '45deg' }],
62
+ },
63
+ });
64
+ const hitSlop = scaling.scaleHorizontal(Math.max(11, (44 - designSize) / 2));
65
+
66
+ return { styles, hitSlop };
67
+ };
@@ -0,0 +1,2 @@
1
+ export { EmptyListView } from './ui/EmptyListView';
2
+ export type { EmptyListViewProps } from './types/types';
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
3
+ import type { TypographyProps } from '../../Typography';
4
+
5
+ export interface EmptyListViewProps {
6
+ visual?: ReactNode;
7
+ title?: ReactNode;
8
+ description?: ReactNode;
9
+ titleVariant?: TypographyProps['variant'];
10
+ descriptionVariant?: TypographyProps['variant'];
11
+ actionLabel?: string;
12
+ onActionPress?: () => void;
13
+ action?: ReactNode;
14
+ /** Labels the optional visual without grouping the readable text and action. */
15
+ accessibilityLabel?: string;
16
+ style?: StyleProp<ViewStyle>;
17
+ contentStyle?: StyleProp<ViewStyle>;
18
+ visualStyle?: StyleProp<ViewStyle>;
19
+ titleStyle?: StyleProp<TextStyle>;
20
+ descriptionStyle?: StyleProp<TextStyle>;
21
+ }
@@ -0,0 +1,58 @@
1
+ import { useMemo } from 'react';
2
+ import { View } from 'react-native';
3
+ import { Button } from '../../Button';
4
+ import { Typography } from '../../Typography';
5
+ import { useUITheme } from '../../../theme';
6
+ import { useScaling } from '../../../utils';
7
+ import { getStyles } from './styles';
8
+ import type { EmptyListViewProps } from '../types/types';
9
+
10
+ export const EmptyListView = ({
11
+ visual,
12
+ title,
13
+ description,
14
+ titleVariant = 'title',
15
+ descriptionVariant = 'body',
16
+ actionLabel,
17
+ onActionPress,
18
+ action,
19
+ accessibilityLabel,
20
+ style,
21
+ contentStyle,
22
+ visualStyle,
23
+ titleStyle,
24
+ descriptionStyle,
25
+ }: EmptyListViewProps) => {
26
+ const { spacing } = useUITheme();
27
+ const scaling = useScaling();
28
+ const styles = useMemo(() => getStyles(spacing, scaling), [spacing, scaling]);
29
+
30
+ return (
31
+ <View style={[styles.container, style]}>
32
+ <View style={[styles.content, contentStyle]}>
33
+ {visual != null && visual !== false ? (
34
+ <View
35
+ accessible={accessibilityLabel !== undefined}
36
+ accessibilityLabel={accessibilityLabel}
37
+ style={[styles.visual, visualStyle]}
38
+ >
39
+ {typeof visual === 'string' || typeof visual === 'number'
40
+ ? <Typography text={visual} /> : visual}
41
+ </View>
42
+ ) : null}
43
+ {title != null ? (
44
+ typeof title === 'string' || typeof title === 'number'
45
+ ? <Typography text={title} variant={titleVariant} style={[styles.title, titleStyle]} />
46
+ : title
47
+ ) : null}
48
+ {description != null ? (
49
+ typeof description === 'string' || typeof description === 'number'
50
+ ? <Typography text={description} variant={descriptionVariant} color="textMuted" style={[styles.description, descriptionStyle]} />
51
+ : description
52
+ ) : null}
53
+ {action != null && action !== false ? action : actionLabel && onActionPress
54
+ ? <Button text={actionLabel} onPress={onActionPress} /> : null}
55
+ </View>
56
+ </View>
57
+ );
58
+ };
@@ -0,0 +1,32 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import type { UISpacing } from '../../../theme';
3
+ import type { ScalingFunctions } from '../../../utils';
4
+
5
+ export const getStyles = (spacing: UISpacing, scaling: ScalingFunctions) => {
6
+ const styles = StyleSheet.create({
7
+ container: {
8
+ flexGrow: 1,
9
+ justifyContent: 'center',
10
+ alignItems: 'center',
11
+ paddingHorizontal: scaling.scaleHorizontal(spacing.lg),
12
+ paddingVertical: scaling.scaleVertical(spacing.xl),
13
+ },
14
+ content: {
15
+ width: '100%',
16
+ alignItems: 'center',
17
+ gap: scaling.scaleVertical(spacing.md),
18
+ },
19
+ visual: {
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ },
23
+ title: {
24
+ textAlign: 'center',
25
+ },
26
+ description: {
27
+ textAlign: 'center',
28
+ },
29
+ });
30
+
31
+ return styles;
32
+ };
@@ -0,0 +1,2 @@
1
+ export { PhotoPreview } from './ui/PhotoPreview';
2
+ export type { PhotoPreviewProps } from './types/types';
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ImageResizeMode, ImageSourcePropType, ImageStyle, StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface PhotoPreviewProps {
5
+ source: ImageSourcePropType;
6
+ width?: number;
7
+ height?: number;
8
+ aspectRatio?: number;
9
+ borderRadius?: number;
10
+ resizeMode?: ImageResizeMode;
11
+ overlay?: ReactNode;
12
+ removeIcon?: ReactNode;
13
+ onRemovePress?: () => void;
14
+ removeAccessibilityLabel?: string;
15
+ removeAccessibilityHint?: string;
16
+ onPress?: () => void;
17
+ accessibilityLabel?: string;
18
+ accessibilityHint?: string;
19
+ disabled?: boolean;
20
+ style?: StyleProp<ViewStyle>;
21
+ imageStyle?: StyleProp<ImageStyle>;
22
+ }
@@ -0,0 +1,83 @@
1
+ import { useMemo } from 'react';
2
+ import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { useUITheme } from '../../../theme';
4
+ import { useScaling } from '../../../utils';
5
+ import { getStyles } from './styles';
6
+ import type { PhotoPreviewProps } from '../types/types';
7
+
8
+ export const PhotoPreview = ({
9
+ source,
10
+ width,
11
+ height,
12
+ aspectRatio,
13
+ borderRadius,
14
+ resizeMode = 'cover',
15
+ overlay,
16
+ removeIcon,
17
+ onRemovePress,
18
+ removeAccessibilityLabel,
19
+ removeAccessibilityHint,
20
+ onPress,
21
+ accessibilityLabel,
22
+ accessibilityHint,
23
+ disabled = false,
24
+ style,
25
+ imageStyle,
26
+ }: PhotoPreviewProps) => {
27
+ const { colors, radius, spacing } = useUITheme();
28
+ const scaling = useScaling();
29
+ const { styles, removeHitSlop } = useMemo(
30
+ () => getStyles(colors, radius, spacing, scaling, width, height, aspectRatio, borderRadius),
31
+ [colors, radius, spacing, scaling, width, height, aspectRatio, borderRadius],
32
+ );
33
+ const image = (
34
+ <Image
35
+ source={source}
36
+ resizeMode={resizeMode}
37
+ accessible={!onPress}
38
+ accessibilityRole={onPress ? undefined : 'image'}
39
+ accessibilityLabel={onPress ? undefined : accessibilityLabel}
40
+ accessibilityHint={onPress ? undefined : accessibilityHint}
41
+ style={[styles.image, imageStyle]}
42
+ />
43
+ );
44
+
45
+ return (
46
+ <View style={[styles.container, style]}>
47
+ {onPress ? (
48
+ <Pressable
49
+ accessibilityRole="button"
50
+ accessibilityLabel={accessibilityLabel}
51
+ accessibilityHint={accessibilityHint}
52
+ accessibilityState={{ disabled }}
53
+ disabled={disabled}
54
+ onPress={onPress}
55
+ style={StyleSheet.absoluteFill}
56
+ >
57
+ {image}
58
+ </Pressable>
59
+ ) : image}
60
+ {overlay != null && overlay !== false ? (
61
+ <View pointerEvents={disabled ? 'none' : 'box-none'} style={styles.overlay}>
62
+ {typeof overlay === 'string' || typeof overlay === 'number' ? <Text>{overlay}</Text> : overlay}
63
+ </View>
64
+ ) : null}
65
+ {onRemovePress ? (
66
+ <Pressable
67
+ accessibilityRole="button"
68
+ accessibilityLabel={removeAccessibilityLabel ?? 'Remove photo'}
69
+ accessibilityHint={removeAccessibilityHint}
70
+ accessibilityState={{ disabled }}
71
+ disabled={disabled}
72
+ hitSlop={removeHitSlop}
73
+ onPress={onRemovePress}
74
+ style={styles.remove}
75
+ >
76
+ {removeIcon === undefined ? <Text style={styles.removeText}>×</Text>
77
+ : typeof removeIcon === 'string' || typeof removeIcon === 'number'
78
+ ? <Text style={styles.removeText}>{removeIcon}</Text> : removeIcon}
79
+ </Pressable>
80
+ ) : null}
81
+ </View>
82
+ );
83
+ };
@@ -0,0 +1,68 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import type { UIColors, UIRadius, UISpacing } from '../../../theme';
3
+ import type { ScalingFunctions } from '../../../utils';
4
+ import type { PhotoPreviewProps } from '../types/types';
5
+
6
+ const positiveOrUndefined = (value: number | undefined) =>
7
+ value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined;
8
+
9
+ export const getStyles = (
10
+ colors: UIColors,
11
+ radius: UIRadius,
12
+ spacing: UISpacing,
13
+ scaling: ScalingFunctions,
14
+ width: PhotoPreviewProps['width'],
15
+ height: PhotoPreviewProps['height'],
16
+ aspectRatio: PhotoPreviewProps['aspectRatio'],
17
+ borderRadius: PhotoPreviewProps['borderRadius'],
18
+ ) => {
19
+ const designWidth = positiveOrUndefined(width);
20
+ const designHeight = positiveOrUndefined(height);
21
+ const ratio = positiveOrUndefined(aspectRatio);
22
+ const useWidth = designWidth !== undefined || designHeight === undefined;
23
+ const derivedRatio = ratio ?? (designWidth === undefined || designHeight === undefined ? 1 : undefined);
24
+ const containerWidth = useWidth ? scaling.scaleHorizontal(designWidth ?? 120) : undefined;
25
+ const containerHeight = derivedRatio === undefined || !useWidth
26
+ ? designHeight === undefined ? undefined : scaling.scaleVertical(designHeight)
27
+ : undefined;
28
+ const designRadius = borderRadius !== undefined && Number.isFinite(borderRadius)
29
+ ? Math.max(0, borderRadius) : radius.md;
30
+
31
+ const styles = StyleSheet.create({
32
+ container: {
33
+ width: containerWidth,
34
+ height: containerHeight,
35
+ aspectRatio: derivedRatio,
36
+ borderRadius: scaling.scaleHorizontal(designRadius),
37
+ backgroundColor: colors.surface,
38
+ overflow: 'hidden',
39
+ },
40
+ image: {
41
+ ...StyleSheet.absoluteFill,
42
+ },
43
+ overlay: {
44
+ ...StyleSheet.absoluteFill,
45
+ },
46
+ remove: {
47
+ position: 'absolute',
48
+ top: scaling.scaleVertical(spacing.sm),
49
+ right: scaling.scaleHorizontal(spacing.sm),
50
+ zIndex: 1,
51
+ width: scaling.scaleHorizontal(28),
52
+ height: scaling.scaleHorizontal(28),
53
+ borderRadius: scaling.scaleHorizontal(14),
54
+ backgroundColor: colors.surface,
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ },
58
+ removeText: {
59
+ color: colors.text,
60
+ fontSize: scaling.scaleFontSize(22),
61
+ lineHeight: scaling.scaleLineHeight(24),
62
+ textAlign: 'center',
63
+ },
64
+ });
65
+ const removeHitSlop = scaling.scaleHorizontal(8);
66
+
67
+ return { styles, removeHitSlop };
68
+ };
@@ -0,0 +1,2 @@
1
+ export { Switch } from './ui/Switch';
2
+ export type { SwitchProps } from './types/types';
@@ -0,0 +1,21 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import { ReduceMotion, useSharedValue, withTiming } from 'react-native-reanimated';
3
+ import type { SwitchProps } from '../types/types';
4
+
5
+ export const useSwitch = (
6
+ value: boolean,
7
+ disabled: boolean,
8
+ onValueChange: SwitchProps['onValueChange'],
9
+ ) => {
10
+ const progress = useSharedValue(value ? 1 : 0);
11
+
12
+ useEffect(() => {
13
+ progress.value = withTiming(value ? 1 : 0, { duration: 200, reduceMotion: ReduceMotion.System });
14
+ }, [progress, value]);
15
+
16
+ const onPress = useCallback(() => {
17
+ if (!disabled) onValueChange?.(!value);
18
+ }, [disabled, onValueChange, value]);
19
+
20
+ return { onPress, progress };
21
+ };
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface SwitchProps {
5
+ value: boolean;
6
+ onValueChange?: (value: boolean) => void;
7
+ disabled?: boolean;
8
+ width?: number;
9
+ height?: number;
10
+ thumbSize?: number;
11
+ activeTrackColor?: string;
12
+ inactiveTrackColor?: string;
13
+ disabledTrackColor?: string;
14
+ thumbColor?: string;
15
+ disabledThumbColor?: string;
16
+ thumb?: ReactNode;
17
+ accessibilityLabel?: string;
18
+ accessibilityHint?: string;
19
+ style?: StyleProp<ViewStyle>;
20
+ }
@@ -0,0 +1,60 @@
1
+ import { useMemo } from 'react';
2
+ import { Pressable, Text } from 'react-native';
3
+ import Reanimated from 'react-native-reanimated';
4
+ import { useUITheme } from '../../../theme';
5
+ import { useScaling } from '../../../utils';
6
+ import { useSwitch } from '../presenters/useSwitch';
7
+ import { getStyles, getSwitchGeometry, useSwitchAnimatedStyles } from './styles';
8
+ import type { SwitchProps } from '../types/types';
9
+
10
+ const AnimatedPressable = Reanimated.createAnimatedComponent(Pressable);
11
+
12
+ export const Switch = ({
13
+ value,
14
+ onValueChange,
15
+ disabled = false,
16
+ width = 40,
17
+ height = 24,
18
+ thumbSize = 20,
19
+ activeTrackColor,
20
+ inactiveTrackColor,
21
+ disabledTrackColor,
22
+ thumbColor,
23
+ disabledThumbColor,
24
+ thumb,
25
+ accessibilityLabel,
26
+ accessibilityHint,
27
+ style,
28
+ }: SwitchProps) => {
29
+ const { colors } = useUITheme();
30
+ const scaling = useScaling();
31
+ const geometry = useMemo(() => getSwitchGeometry(scaling, width, height, thumbSize), [scaling, width, height, thumbSize]);
32
+ const styles = useMemo(
33
+ () => getStyles(geometry, disabled ? disabledThumbColor ?? colors.surface : thumbColor ?? colors.background),
34
+ [colors.background, colors.surface, disabled, disabledThumbColor, geometry, thumbColor],
35
+ );
36
+ const { onPress, progress } = useSwitch(value, disabled, onValueChange);
37
+ const { trackStyle, thumbStyle } = useSwitchAnimatedStyles(
38
+ progress,
39
+ geometry.travelDistance,
40
+ disabled ? disabledTrackColor ?? colors.disabled : inactiveTrackColor ?? colors.border,
41
+ disabled ? disabledTrackColor ?? colors.disabled : activeTrackColor ?? colors.primary,
42
+ );
43
+
44
+ return (
45
+ <AnimatedPressable
46
+ accessibilityRole="switch"
47
+ accessibilityLabel={accessibilityLabel}
48
+ accessibilityHint={accessibilityHint}
49
+ accessibilityState={{ checked: value, disabled }}
50
+ disabled={disabled}
51
+ hitSlop={geometry.hitSlop}
52
+ onPress={onPress}
53
+ style={[styles.track, trackStyle, style]}
54
+ >
55
+ <Reanimated.View style={[styles.thumb, thumbStyle]} pointerEvents="none">
56
+ {typeof thumb === 'string' || typeof thumb === 'number' ? <Text>{thumb}</Text> : thumb}
57
+ </Reanimated.View>
58
+ </AnimatedPressable>
59
+ );
60
+ };
@@ -0,0 +1,70 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { interpolateColor, useAnimatedStyle } from 'react-native-reanimated';
3
+ import type { SharedValue } from 'react-native-reanimated';
4
+ import type { ScalingFunctions } from '../../../utils';
5
+
6
+ const positiveOr = (value: number, fallback: number) => Number.isFinite(value) && value > 0 ? value : fallback;
7
+
8
+ export const getSwitchGeometry = (scaling: ScalingFunctions, width: number, height: number, thumbSize: number) => {
9
+ const designWidth = positiveOr(width, 40);
10
+ const designHeight = positiveOr(height, 24);
11
+ const designPadding = Math.min(2, designWidth / 4, designHeight / 4);
12
+ const designThumb = Math.min(positiveOr(thumbSize, 20), Math.min(designWidth, designHeight) - 2 * designPadding);
13
+ const trackWidth = scaling.scaleHorizontal(designWidth);
14
+ const trackHeight = scaling.scaleHorizontal(designHeight);
15
+ const padding = scaling.scaleHorizontal(designPadding);
16
+ const thumb = scaling.scaleHorizontal(designThumb);
17
+
18
+ return {
19
+ trackWidth,
20
+ trackHeight,
21
+ padding,
22
+ thumb,
23
+ travelDistance: Math.max(0, trackWidth - 2 * padding - thumb),
24
+ hitSlop: {
25
+ top: scaling.scaleHorizontal(Math.max(10, (44 - designHeight) / 2)),
26
+ bottom: scaling.scaleHorizontal(Math.max(10, (44 - designHeight) / 2)),
27
+ left: scaling.scaleHorizontal(Math.max(10, (44 - designWidth) / 2)),
28
+ right: scaling.scaleHorizontal(Math.max(10, (44 - designWidth) / 2)),
29
+ },
30
+ };
31
+ };
32
+
33
+ export const getStyles = (geometry: ReturnType<typeof getSwitchGeometry>, thumbColor: string) => {
34
+ const styles = StyleSheet.create({
35
+ track: {
36
+ width: geometry.trackWidth,
37
+ height: geometry.trackHeight,
38
+ padding: geometry.padding,
39
+ borderRadius: geometry.trackHeight / 2,
40
+ justifyContent: 'center',
41
+ flexShrink: 0,
42
+ },
43
+ thumb: {
44
+ width: geometry.thumb,
45
+ height: geometry.thumb,
46
+ borderRadius: geometry.thumb / 2,
47
+ backgroundColor: thumbColor,
48
+ alignItems: 'center',
49
+ justifyContent: 'center',
50
+ },
51
+ });
52
+
53
+ return styles;
54
+ };
55
+
56
+ export const useSwitchAnimatedStyles = (
57
+ progress: SharedValue<number>,
58
+ travelDistance: number,
59
+ inactiveTrackColor: string,
60
+ activeTrackColor: string,
61
+ ) => {
62
+ const trackStyle = useAnimatedStyle(() => ({
63
+ backgroundColor: interpolateColor(progress.value, [0, 1], [inactiveTrackColor, activeTrackColor]),
64
+ }), [activeTrackColor, inactiveTrackColor]);
65
+ const thumbStyle = useAnimatedStyle(() => ({
66
+ transform: [{ translateX: progress.value * travelDistance }],
67
+ }), [travelDistance]);
68
+
69
+ return { trackStyle, thumbStyle };
70
+ };
package/src/index.ts CHANGED
@@ -1,12 +1,17 @@
1
+ export * from './components/Avatar';
1
2
  export * from './components/Button';
2
3
  export * from './components/BottomModal';
4
+ export * from './components/Checkbox';
3
5
  export * from './components/ConnectionContainer';
4
6
  export * from './components/CustomAlert';
7
+ export * from './components/EmptyListView';
5
8
  export * from './components/HeaderWithBackButton';
6
9
  export * from './components/Input';
7
10
  export * from './components/Loader';
8
11
  export * from './components/Logger';
12
+ export * from './components/PhotoPreview';
9
13
  export * from './components/ScreenContainer';
14
+ export * from './components/Switch';
10
15
  export * from './components/Typography';
11
16
  export * from './provider';
12
17
  export * from './theme';