@cyberkaidev/ui 0.1.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.
Files changed (152) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +721 -0
  3. package/lib/module/ThemeProvider.js +84 -0
  4. package/lib/module/ThemeProvider.js.map +1 -0
  5. package/lib/module/components/AppBar.js +185 -0
  6. package/lib/module/components/AppBar.js.map +1 -0
  7. package/lib/module/components/BounceEffect.js +50 -0
  8. package/lib/module/components/BounceEffect.js.map +1 -0
  9. package/lib/module/components/Button.js +48 -0
  10. package/lib/module/components/Button.js.map +1 -0
  11. package/lib/module/components/Chips.js +94 -0
  12. package/lib/module/components/Chips.js.map +1 -0
  13. package/lib/module/components/IconButton.js +71 -0
  14. package/lib/module/components/IconButton.js.map +1 -0
  15. package/lib/module/components/InforList.js +92 -0
  16. package/lib/module/components/InforList.js.map +1 -0
  17. package/lib/module/components/LoadingApp.js +58 -0
  18. package/lib/module/components/LoadingApp.js.map +1 -0
  19. package/lib/module/components/Overlays.js +20 -0
  20. package/lib/module/components/Overlays.js.map +1 -0
  21. package/lib/module/components/RippleEffect.js +178 -0
  22. package/lib/module/components/RippleEffect.js.map +1 -0
  23. package/lib/module/components/Scaffold.js +172 -0
  24. package/lib/module/components/Scaffold.js.map +1 -0
  25. package/lib/module/components/SearchBar.js +161 -0
  26. package/lib/module/components/SearchBar.js.map +1 -0
  27. package/lib/module/components/SegmentedButton.js +151 -0
  28. package/lib/module/components/SegmentedButton.js.map +1 -0
  29. package/lib/module/components/ShapedButton.js +58 -0
  30. package/lib/module/components/ShapedButton.js.map +1 -0
  31. package/lib/module/components/SlideModal.js +196 -0
  32. package/lib/module/components/SlideModal.js.map +1 -0
  33. package/lib/module/components/TabBar.js +107 -0
  34. package/lib/module/components/TabBar.js.map +1 -0
  35. package/lib/module/components/Text.js +47 -0
  36. package/lib/module/components/Text.js.map +1 -0
  37. package/lib/module/components/TextButton.js +53 -0
  38. package/lib/module/components/TextButton.js.map +1 -0
  39. package/lib/module/components/TextInput.js +83 -0
  40. package/lib/module/components/TextInput.js.map +1 -0
  41. package/lib/module/components/Toast.js +101 -0
  42. package/lib/module/components/Toast.js.map +1 -0
  43. package/lib/module/hooks/useAppBarScroll.js +20 -0
  44. package/lib/module/hooks/useAppBarScroll.js.map +1 -0
  45. package/lib/module/hooks/usePressAndHoldRepeat.js +71 -0
  46. package/lib/module/hooks/usePressAndHoldRepeat.js.map +1 -0
  47. package/lib/module/icons/Icon.js +85 -0
  48. package/lib/module/icons/Icon.js.map +1 -0
  49. package/lib/module/index.js +42 -0
  50. package/lib/module/index.js.map +1 -0
  51. package/lib/module/package.json +1 -0
  52. package/lib/module/responsive.js +28 -0
  53. package/lib/module/responsive.js.map +1 -0
  54. package/lib/module/stores/useLoadingApp.js +14 -0
  55. package/lib/module/stores/useLoadingApp.js.map +1 -0
  56. package/lib/module/stores/useToast.js +24 -0
  57. package/lib/module/stores/useToast.js.map +1 -0
  58. package/lib/module/theme.js +86 -0
  59. package/lib/module/theme.js.map +1 -0
  60. package/lib/module/types.js +2 -0
  61. package/lib/module/types.js.map +1 -0
  62. package/lib/typescript/jest.setup.d.ts +2 -0
  63. package/lib/typescript/jest.setup.d.ts.map +1 -0
  64. package/lib/typescript/package.json +1 -0
  65. package/lib/typescript/src/ThemeProvider.d.ts +39 -0
  66. package/lib/typescript/src/ThemeProvider.d.ts.map +1 -0
  67. package/lib/typescript/src/components/AppBar.d.ts +33 -0
  68. package/lib/typescript/src/components/AppBar.d.ts.map +1 -0
  69. package/lib/typescript/src/components/BounceEffect.d.ts +12 -0
  70. package/lib/typescript/src/components/BounceEffect.d.ts.map +1 -0
  71. package/lib/typescript/src/components/Button.d.ts +24 -0
  72. package/lib/typescript/src/components/Button.d.ts.map +1 -0
  73. package/lib/typescript/src/components/Chips.d.ts +23 -0
  74. package/lib/typescript/src/components/Chips.d.ts.map +1 -0
  75. package/lib/typescript/src/components/IconButton.d.ts +14 -0
  76. package/lib/typescript/src/components/IconButton.d.ts.map +1 -0
  77. package/lib/typescript/src/components/InforList.d.ts +28 -0
  78. package/lib/typescript/src/components/InforList.d.ts.map +1 -0
  79. package/lib/typescript/src/components/LoadingApp.d.ts +8 -0
  80. package/lib/typescript/src/components/LoadingApp.d.ts.map +1 -0
  81. package/lib/typescript/src/components/Overlays.d.ts +12 -0
  82. package/lib/typescript/src/components/Overlays.d.ts.map +1 -0
  83. package/lib/typescript/src/components/RippleEffect.d.ts +18 -0
  84. package/lib/typescript/src/components/RippleEffect.d.ts.map +1 -0
  85. package/lib/typescript/src/components/Scaffold.d.ts +62 -0
  86. package/lib/typescript/src/components/Scaffold.d.ts.map +1 -0
  87. package/lib/typescript/src/components/SearchBar.d.ts +20 -0
  88. package/lib/typescript/src/components/SearchBar.d.ts.map +1 -0
  89. package/lib/typescript/src/components/SegmentedButton.d.ts +37 -0
  90. package/lib/typescript/src/components/SegmentedButton.d.ts.map +1 -0
  91. package/lib/typescript/src/components/ShapedButton.d.ts +18 -0
  92. package/lib/typescript/src/components/ShapedButton.d.ts.map +1 -0
  93. package/lib/typescript/src/components/SlideModal.d.ts +15 -0
  94. package/lib/typescript/src/components/SlideModal.d.ts.map +1 -0
  95. package/lib/typescript/src/components/TabBar.d.ts +20 -0
  96. package/lib/typescript/src/components/TabBar.d.ts.map +1 -0
  97. package/lib/typescript/src/components/Text.d.ts +26 -0
  98. package/lib/typescript/src/components/Text.d.ts.map +1 -0
  99. package/lib/typescript/src/components/TextButton.d.ts +15 -0
  100. package/lib/typescript/src/components/TextButton.d.ts.map +1 -0
  101. package/lib/typescript/src/components/TextInput.d.ts +22 -0
  102. package/lib/typescript/src/components/TextInput.d.ts.map +1 -0
  103. package/lib/typescript/src/components/Toast.d.ts +4 -0
  104. package/lib/typescript/src/components/Toast.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/useAppBarScroll.d.ts +10 -0
  106. package/lib/typescript/src/hooks/useAppBarScroll.d.ts.map +1 -0
  107. package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts +21 -0
  108. package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts.map +1 -0
  109. package/lib/typescript/src/icons/Icon.d.ts +64 -0
  110. package/lib/typescript/src/icons/Icon.d.ts.map +1 -0
  111. package/lib/typescript/src/index.d.ts +30 -0
  112. package/lib/typescript/src/index.d.ts.map +1 -0
  113. package/lib/typescript/src/responsive.d.ts +15 -0
  114. package/lib/typescript/src/responsive.d.ts.map +1 -0
  115. package/lib/typescript/src/stores/useLoadingApp.d.ts +14 -0
  116. package/lib/typescript/src/stores/useLoadingApp.d.ts.map +1 -0
  117. package/lib/typescript/src/stores/useToast.d.ts +25 -0
  118. package/lib/typescript/src/stores/useToast.d.ts.map +1 -0
  119. package/lib/typescript/src/theme.d.ts +51 -0
  120. package/lib/typescript/src/theme.d.ts.map +1 -0
  121. package/lib/typescript/src/types.d.ts +26 -0
  122. package/lib/typescript/src/types.d.ts.map +1 -0
  123. package/package.json +189 -0
  124. package/src/ThemeProvider.tsx +108 -0
  125. package/src/components/AppBar.tsx +226 -0
  126. package/src/components/BounceEffect.tsx +53 -0
  127. package/src/components/Button.tsx +70 -0
  128. package/src/components/Chips.tsx +115 -0
  129. package/src/components/IconButton.tsx +84 -0
  130. package/src/components/InforList.tsx +110 -0
  131. package/src/components/LoadingApp.tsx +55 -0
  132. package/src/components/Overlays.tsx +21 -0
  133. package/src/components/RippleEffect.tsx +244 -0
  134. package/src/components/Scaffold.tsx +219 -0
  135. package/src/components/SearchBar.tsx +166 -0
  136. package/src/components/SegmentedButton.tsx +206 -0
  137. package/src/components/ShapedButton.tsx +74 -0
  138. package/src/components/SlideModal.tsx +232 -0
  139. package/src/components/TabBar.tsx +122 -0
  140. package/src/components/Text.tsx +70 -0
  141. package/src/components/TextButton.tsx +57 -0
  142. package/src/components/TextInput.tsx +108 -0
  143. package/src/components/Toast.tsx +95 -0
  144. package/src/hooks/useAppBarScroll.ts +19 -0
  145. package/src/hooks/usePressAndHoldRepeat.ts +85 -0
  146. package/src/icons/Icon.tsx +51 -0
  147. package/src/index.tsx +64 -0
  148. package/src/responsive.ts +32 -0
  149. package/src/stores/useLoadingApp.ts +19 -0
  150. package/src/stores/useToast.ts +36 -0
  151. package/src/theme.ts +125 -0
  152. package/src/types.ts +27 -0
@@ -0,0 +1,57 @@
1
+ import type { ReactNode } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+
4
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
5
+ import type { ThemeType } from '../theme';
6
+ import type { FontSizeType } from '../types';
7
+ import { Button } from './Button';
8
+ import { Text } from './Text';
9
+
10
+ type Props = {
11
+ children: ReactNode;
12
+ size?: FontSizeType;
13
+ /** Text color. Default: theme `primary`. */
14
+ color?: string;
15
+ disabled?: boolean;
16
+ onPress: () => void;
17
+ testID?: string;
18
+ };
19
+
20
+ /** Text-only button, no fill. */
21
+ export function TextButton({
22
+ children,
23
+ size,
24
+ color,
25
+ disabled = false,
26
+ onPress,
27
+ testID,
28
+ }: Props) {
29
+ const { colors } = useTheme();
30
+ const styles = useThemedStyles(makeStyles);
31
+ const opacity = disabled ? 0.5 : 1;
32
+
33
+ return (
34
+ <Button
35
+ testID={testID ?? 'text-button'}
36
+ style={[styles.container, { opacity }]}
37
+ onPress={onPress}
38
+ disabled={disabled}
39
+ hitSlop={{ top: 20, bottom: 20, left: 25, right: 15 }}
40
+ >
41
+ <Text weight="medium" color={color ?? colors.primary} size={size}>
42
+ {children}
43
+ </Text>
44
+ </Button>
45
+ );
46
+ }
47
+
48
+ function makeStyles(t: ThemeType) {
49
+ return StyleSheet.create({
50
+ container: {
51
+ paddingHorizontal: t.spaces.horizontal.s,
52
+ paddingVertical: t.spaces.vertical.xs,
53
+ borderRadius: t.borderRadius.radius_20,
54
+ overflow: 'hidden',
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { StyleSheet, TextInput as RNTextInput, View } from 'react-native';
3
+ import type { TextInputProps } from 'react-native';
4
+
5
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
6
+ import type { ThemeType } from '../theme';
7
+ import { heightDP } from '../responsive';
8
+ import { Text } from './Text';
9
+
10
+ type Props = {
11
+ value: string;
12
+ placeholder: string;
13
+ /** Helper text shown below the field (in the error color). */
14
+ supportingText?: string;
15
+ isError?: boolean;
16
+ autoFocus?: TextInputProps['autoFocus'];
17
+ autoCapitalize?: TextInputProps['autoCapitalize'];
18
+ autoCorrect?: TextInputProps['autoCorrect'];
19
+ autoComplete?: TextInputProps['autoComplete'];
20
+ inputMode?: TextInputProps['inputMode'];
21
+ onChangeText: (text: string) => void;
22
+ onBlur?: TextInputProps['onBlur'];
23
+ /** Called on submit when the value is non-empty. */
24
+ onEnter?: () => void;
25
+ };
26
+
27
+ /** Single-line text field with a supporting message and error state. */
28
+ export function TextInput({
29
+ value,
30
+ placeholder,
31
+ supportingText,
32
+ isError = false,
33
+ autoFocus,
34
+ autoCapitalize,
35
+ autoCorrect,
36
+ autoComplete,
37
+ inputMode,
38
+ onEnter,
39
+ onChangeText,
40
+ onBlur,
41
+ }: Props) {
42
+ const { colors, spaces } = useTheme();
43
+ const styles = useThemedStyles(makeStyles);
44
+ const [isFocused, setIsFocused] = React.useState(false);
45
+
46
+ const borderColor = isError
47
+ ? colors.red
48
+ : isFocused
49
+ ? colors.primary
50
+ : colors.border;
51
+
52
+ return (
53
+ <View style={styles.container}>
54
+ <RNTextInput
55
+ testID="text-input-field"
56
+ style={[
57
+ styles.input,
58
+ {
59
+ backgroundColor: colors.on_surface,
60
+ color: colors.text,
61
+ borderColor,
62
+ },
63
+ ]}
64
+ placeholder={placeholder}
65
+ autoFocus={autoFocus}
66
+ autoCapitalize={autoCapitalize}
67
+ autoCorrect={autoCorrect}
68
+ autoComplete={autoComplete}
69
+ inputMode={inputMode}
70
+ cursorColor={colors.primary}
71
+ placeholderTextColor={colors.text}
72
+ onChangeText={onChangeText}
73
+ onFocus={() => setIsFocused(true)}
74
+ onBlur={(event) => {
75
+ setIsFocused(false);
76
+ onBlur?.(event);
77
+ }}
78
+ value={value}
79
+ onSubmitEditing={() => {
80
+ if (value.trim().length > 0) onEnter?.();
81
+ }}
82
+ returnKeyType="done"
83
+ />
84
+ {supportingText ? (
85
+ <Text color={colors.red} size="xs" marginL={spaces.horizontal.s}>
86
+ {supportingText}
87
+ </Text>
88
+ ) : null}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ function makeStyles(t: ThemeType) {
94
+ return StyleSheet.create({
95
+ container: {
96
+ width: '100%',
97
+ gap: t.spaces.vertical.xs,
98
+ },
99
+ input: {
100
+ width: '100%',
101
+ height: heightDP(7),
102
+ borderWidth: 1,
103
+ borderRadius: t.borderRadius.radius_15,
104
+ paddingHorizontal: t.spaces.horizontal.l,
105
+ fontSize: t.fontSizes.s,
106
+ },
107
+ });
108
+ }
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import { Animated, StyleSheet, View } from 'react-native';
3
+
4
+ import { Icon } from '../icons/Icon';
5
+ import type { IconNameType } from '../icons/Icon';
6
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
7
+ import { useToast } from '../stores/useToast';
8
+ import type { ThemeType } from '../theme';
9
+ import type { ToastVariantType } from '../types';
10
+ import { Text } from './Text';
11
+
12
+ const VISIBLE_MS = 3000;
13
+ const FADE_MS = 300;
14
+
15
+ const iconByVariant: Record<ToastVariantType, IconNameType | null> = {
16
+ error: 'error',
17
+ success: 'check',
18
+ info: null,
19
+ };
20
+
21
+ /** Transient centered notice. Drive it with `useToast().showToast(...)`. */
22
+ export function Toast() {
23
+ const { colors } = useTheme();
24
+ const styles = useThemedStyles(makeStyles);
25
+ const { visible, variant, message, hideToast } = useToast();
26
+ const opacity = React.useRef(new Animated.Value(0)).current;
27
+
28
+ React.useEffect(() => {
29
+ if (!visible) return;
30
+
31
+ let hideTimer: ReturnType<typeof setTimeout>;
32
+ const enter = Animated.timing(opacity, {
33
+ toValue: 1,
34
+ duration: FADE_MS,
35
+ useNativeDriver: true,
36
+ });
37
+
38
+ enter.start(() => {
39
+ hideTimer = setTimeout(() => {
40
+ Animated.timing(opacity, {
41
+ toValue: 0,
42
+ duration: FADE_MS,
43
+ useNativeDriver: true,
44
+ }).start(() => hideToast());
45
+ }, VISIBLE_MS);
46
+ });
47
+
48
+ return () => {
49
+ enter.stop();
50
+ clearTimeout(hideTimer);
51
+ };
52
+ // `message`/`variant` restart the fade + auto-hide cycle when a new toast is
53
+ // shown while one is already visible.
54
+ }, [visible, message, variant, opacity, hideToast]);
55
+
56
+ if (!visible) return null;
57
+
58
+ const iconName = iconByVariant[variant];
59
+ const iconColor = variant === 'error' ? colors.red : colors.green;
60
+
61
+ return (
62
+ <Animated.View style={[styles.background, { opacity }]}>
63
+ <View style={[styles.content, { backgroundColor: colors.background }]}>
64
+ {iconName && <Icon name={iconName} color={iconColor} />}
65
+ <Text weight="medium" size="xs" textAlign="center">
66
+ {message}
67
+ </Text>
68
+ </View>
69
+ </Animated.View>
70
+ );
71
+ }
72
+
73
+ function makeStyles(t: ThemeType) {
74
+ return StyleSheet.create({
75
+ background: {
76
+ position: 'absolute',
77
+ top: 0,
78
+ left: 0,
79
+ right: 0,
80
+ bottom: 0,
81
+ justifyContent: 'center',
82
+ alignItems: 'center',
83
+ pointerEvents: 'none',
84
+ },
85
+ content: {
86
+ flexDirection: 'row',
87
+ alignItems: 'center',
88
+ gap: t.spaces.horizontal.s,
89
+ borderRadius: t.borderRadius.radius_20,
90
+ paddingVertical: t.spaces.vertical.s,
91
+ paddingHorizontal: t.spaces.horizontal.l,
92
+ pointerEvents: 'none',
93
+ },
94
+ });
95
+ }
@@ -0,0 +1,19 @@
1
+ import {
2
+ useAnimatedScrollHandler,
3
+ useSharedValue,
4
+ } from 'react-native-reanimated';
5
+
6
+ /**
7
+ * Tracks vertical scroll offset for the collapsing `AppBar`. Spread
8
+ * `scrollHandler` onto an `Animated.ScrollView`/`Scaffold` `onScroll` and pass
9
+ * `scrollY` to `<AppBar scrollY={...} />`.
10
+ */
11
+ export function useAppBarScroll() {
12
+ const scrollY = useSharedValue(0);
13
+
14
+ const scrollHandler = useAnimatedScrollHandler((event) => {
15
+ scrollY.value = event.contentOffset.y;
16
+ });
17
+
18
+ return { scrollY, scrollHandler };
19
+ }
@@ -0,0 +1,85 @@
1
+ import React from 'react';
2
+
3
+ type Props = {
4
+ /** Called once on press and repeatedly (accelerating) while held. */
5
+ onTrigger: () => void;
6
+ /** When true, the control fires nothing and any in-flight repeat stops. */
7
+ disabled?: boolean;
8
+ };
9
+
10
+ // Delay after the initial press before the automatic repeat kicks in.
11
+ const HOLD_START_DELAY_MS = 350;
12
+ // Interval between the first few repeats.
13
+ const INITIAL_REPEAT_INTERVAL_MS = 250;
14
+ // Fastest the repeat is allowed to get.
15
+ const MIN_REPEAT_INTERVAL_MS = 40;
16
+ // Each repeat multiplies the interval by this, so the value accelerates while held.
17
+ const ACCELERATION_FACTOR = 0.82;
18
+
19
+ /**
20
+ * Press-and-hold with acceleration, decoupled from any domain.
21
+ *
22
+ * Wire the returned handlers to a pressable: `onPressIn` fires `onTrigger` once
23
+ * immediately (so a quick tap steps by one) and, if the press is held past
24
+ * `HOLD_START_DELAY_MS`, keeps firing it on a shrinking interval until `onPressOut`
25
+ * (or unmount). Clamping / bounds are the caller's concern — firing `onTrigger`
26
+ * when the value is already at a limit is expected to be a no-op.
27
+ */
28
+ export function usePressAndHoldRepeat({ onTrigger, disabled }: Props) {
29
+ const onTriggerRef = React.useRef(onTrigger);
30
+ const startTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
31
+ null
32
+ );
33
+ const repeatTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
34
+ null
35
+ );
36
+
37
+ React.useEffect(() => {
38
+ onTriggerRef.current = onTrigger;
39
+ });
40
+
41
+ const clearTimers = React.useCallback(() => {
42
+ if (startTimeoutRef.current !== null) {
43
+ clearTimeout(startTimeoutRef.current);
44
+ startTimeoutRef.current = null;
45
+ }
46
+ if (repeatTimeoutRef.current !== null) {
47
+ clearTimeout(repeatTimeoutRef.current);
48
+ repeatTimeoutRef.current = null;
49
+ }
50
+ }, []);
51
+
52
+ React.useEffect(() => clearTimers, [clearTimers]);
53
+
54
+ // Stop an in-flight repeat the moment the consumer disables the control.
55
+ React.useEffect(() => {
56
+ if (disabled) clearTimers();
57
+ }, [disabled, clearTimers]);
58
+
59
+ const onPressIn = React.useCallback(() => {
60
+ if (disabled) return;
61
+
62
+ clearTimers();
63
+ onTriggerRef.current();
64
+
65
+ function scheduleRepeat(interval: number) {
66
+ repeatTimeoutRef.current = setTimeout(() => {
67
+ onTriggerRef.current();
68
+ scheduleRepeat(
69
+ Math.max(MIN_REPEAT_INTERVAL_MS, interval * ACCELERATION_FACTOR)
70
+ );
71
+ }, interval);
72
+ }
73
+
74
+ startTimeoutRef.current = setTimeout(() => {
75
+ startTimeoutRef.current = null;
76
+ scheduleRepeat(INITIAL_REPEAT_INTERVAL_MS);
77
+ }, HOLD_START_DELAY_MS);
78
+ }, [disabled, clearTimers]);
79
+
80
+ const onPressOut = React.useCallback(() => {
81
+ clearTimers();
82
+ }, [clearTimers]);
83
+
84
+ return { onPressIn, onPressOut };
85
+ }
@@ -0,0 +1,51 @@
1
+ import { SymbolView } from 'expo-symbols';
2
+ import type { AndroidSymbol, SFSymbol } from 'expo-symbols';
3
+
4
+ import { useTheme } from '../ThemeProvider';
5
+
6
+ // Curated set of generic UI icons. Each entry maps a public name to a
7
+ // per-platform SF Symbol / Android symbol.
8
+ const names = {
9
+ check: { ios: 'checkmark', android: 'check' },
10
+ remove: { ios: 'minus', android: 'remove' },
11
+ add: { ios: 'plus', android: 'add' },
12
+ settings: { ios: 'gear', android: 'settings' },
13
+ arrow_back: { ios: 'chevron.left', android: 'arrow_back' },
14
+ search: { ios: 'magnifyingglass', android: 'search' },
15
+ edit: { ios: 'pencil', android: 'edit' },
16
+ error: { ios: 'x.circle', android: 'error' },
17
+ close: { ios: 'xmark', android: 'close' },
18
+ folder: { ios: 'folder', android: 'folder' },
19
+ question: { ios: 'questionmark', android: 'question_mark' },
20
+ filter: { ios: 'line.3.horizontal.decrease', android: 'filter_list' },
21
+ } satisfies Record<string, { ios: SFSymbol; android: AndroidSymbol }>;
22
+
23
+ export type IconNameType = keyof typeof names;
24
+
25
+ type Props = {
26
+ /** One of the curated icon names. */
27
+ name: IconNameType;
28
+ /** Icon color. Defaults to the theme `primary`. */
29
+ color?: string;
30
+ /** Size in dp. Defaults to the theme `iconSize.m`. */
31
+ size?: number;
32
+ testID?: string;
33
+ };
34
+
35
+ /** Renders a curated UI icon via `expo-symbols` (`SymbolView`). */
36
+ export function Icon({ name, color, size, testID }: Props) {
37
+ const { colors, iconSize } = useTheme();
38
+
39
+ // Built as an object and spread: under the react-native-strict-api types,
40
+ // `SymbolViewProps` doesn't surface the inherited `ViewProps` members
41
+ // (`testID`, a11y props) to JSX excess-property checks, so a literal
42
+ // `testID=` attribute fails to typecheck even though it's forwarded natively.
43
+ const props = {
44
+ testID: testID ?? `icon-${name}`,
45
+ name: names[name],
46
+ tintColor: color ?? colors.primary,
47
+ size: size ?? iconSize.m,
48
+ };
49
+
50
+ return <SymbolView {...props} />;
51
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,64 @@
1
+ // components
2
+ export * from './components/AppBar';
3
+ export * from './components/BounceEffect';
4
+ export * from './components/Button';
5
+ export * from './components/Chips';
6
+ export * from './components/IconButton';
7
+ export * from './components/InforList';
8
+ export * from './components/LoadingApp';
9
+ export * from './components/Overlays';
10
+ export * from './components/RippleEffect';
11
+ export * from './components/Scaffold';
12
+ export * from './components/SearchBar';
13
+ export * from './components/SegmentedButton';
14
+ export * from './components/ShapedButton';
15
+ export * from './components/SlideModal';
16
+ export * from './components/TabBar';
17
+ export * from './components/Text';
18
+ export * from './components/TextButton';
19
+ export * from './components/TextInput';
20
+ export * from './components/Toast';
21
+
22
+ // icons
23
+ export * from './icons/Icon';
24
+
25
+ // theme + provider
26
+ export {
27
+ ThemeProvider,
28
+ useTheme,
29
+ useColors,
30
+ useThemedStyles,
31
+ } from './ThemeProvider';
32
+ export {
33
+ colors,
34
+ fontSizes,
35
+ iconSize,
36
+ spaces,
37
+ borderRadius,
38
+ pageSpacing,
39
+ defaultTokens,
40
+ } from './theme';
41
+ export type {
42
+ ThemeType,
43
+ ThemeTokensType,
44
+ FontSizesType,
45
+ IconSizesType,
46
+ SpacesType,
47
+ SpaceScaleType,
48
+ BorderRadiusType,
49
+ PageSpacingType,
50
+ } from './theme';
51
+
52
+ // responsive helpers
53
+ export { widthDP, heightDP } from './responsive';
54
+
55
+ // hooks
56
+ export * from './hooks/useAppBarScroll';
57
+ export * from './hooks/usePressAndHoldRepeat';
58
+
59
+ // global state
60
+ export { useToast } from './stores/useToast';
61
+ export { useLoadingApp } from './stores/useLoadingApp';
62
+
63
+ // shared style types
64
+ export type * from './types';
@@ -0,0 +1,32 @@
1
+ import { Dimensions, PixelRatio } from 'react-native';
2
+
3
+ const screenWidth = Dimensions.get('window').width;
4
+ const screenHeight = Dimensions.get('window').height;
5
+
6
+ /**
7
+ * Convert a percentage of the screen width to a density-independent pixel value.
8
+ *
9
+ * @param widthPercent Percentage of the screen width (number, or numeric string).
10
+ * @returns The value in dp, rounded to the nearest physical pixel.
11
+ */
12
+ export function widthDP(widthPercent: string | number): number {
13
+ const elemWidth =
14
+ typeof widthPercent === 'number' ? widthPercent : parseFloat(widthPercent);
15
+
16
+ return PixelRatio.roundToNearestPixel((screenWidth * elemWidth) / 100);
17
+ }
18
+
19
+ /**
20
+ * Convert a percentage of the screen height to a density-independent pixel value.
21
+ *
22
+ * @param heightPercent Percentage of the screen height (number, or numeric string).
23
+ * @returns The value in dp, rounded to the nearest physical pixel.
24
+ */
25
+ export function heightDP(heightPercent: string | number): number {
26
+ const elemHeight =
27
+ typeof heightPercent === 'number'
28
+ ? heightPercent
29
+ : parseFloat(heightPercent);
30
+
31
+ return PixelRatio.roundToNearestPixel((screenHeight * elemHeight) / 100);
32
+ }
@@ -0,0 +1,19 @@
1
+ import { create } from 'zustand';
2
+
3
+ type State = {
4
+ visible: boolean;
5
+ };
6
+
7
+ type Actions = {
8
+ /** Toggle the blocking loading overlay. */
9
+ setLoadingVisible: (visible: boolean) => void;
10
+ };
11
+
12
+ /**
13
+ * Global state for the blocking `<LoadingApp />` overlay. Render it once near
14
+ * the root and drive it with `useLoadingApp.getState().setLoadingVisible(...)`.
15
+ */
16
+ export const useLoadingApp = create<State & Actions>((set) => ({
17
+ visible: false,
18
+ setLoadingVisible: (visible) => set({ visible }),
19
+ }));
@@ -0,0 +1,36 @@
1
+ import { create } from 'zustand';
2
+
3
+ import type { ToastVariantType } from '../types';
4
+
5
+ type ShowToastArgs = {
6
+ /** Text shown in the toast. */
7
+ message: string;
8
+ /** Visual intent. Defaults to `'info'`. */
9
+ variant?: ToastVariantType;
10
+ };
11
+
12
+ type State = {
13
+ visible: boolean;
14
+ variant: ToastVariantType;
15
+ message: string;
16
+ };
17
+
18
+ type Actions = {
19
+ /** Show the toast with a message and optional variant. */
20
+ showToast: (args: ShowToastArgs) => void;
21
+ /** Hide the toast. */
22
+ hideToast: () => void;
23
+ };
24
+
25
+ /**
26
+ * Global toast state. Render `<Toast />` once near the root; call
27
+ * `useToast.getState().showToast(...)` (or the hook) from anywhere.
28
+ */
29
+ export const useToast = create<State & Actions>((set) => ({
30
+ visible: false,
31
+ variant: 'info',
32
+ message: '',
33
+ showToast: ({ message, variant = 'info' }) =>
34
+ set({ message, variant, visible: true }),
35
+ hideToast: () => set({ visible: false }),
36
+ }));
package/src/theme.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { heightDP, widthDP } from './responsive';
2
+ import type { ColorsType, FontSizeType, IconSizeType } from './types';
3
+
4
+ /**
5
+ * A ready-made color palette. `ThemeProvider` **requires** a `colors` prop — pass
6
+ * this, spread from it, or provide your own `ColorsType`.
7
+ */
8
+ export const colors: ColorsType = {
9
+ background: 'black',
10
+ background_60: 'rgba(0,0,0,0.6)',
11
+ on_surface: '#1A191E',
12
+ surface: '#100e12',
13
+ surface_90: 'rgba(16, 14, 18, 0.9)',
14
+ border: '#3D3B40',
15
+ text: '#f6f1ff',
16
+ primary: '#4284ff',
17
+ red: '#ff0004',
18
+ green: '#00ff2a',
19
+ amber: '#FFC107',
20
+ transparent: 'transparent',
21
+ };
22
+
23
+ export type FontSizesType = Record<FontSizeType, number>;
24
+
25
+ /** Default font sizes, in dp. */
26
+ export const fontSizes: FontSizesType = {
27
+ xxxl: heightDP(4.3),
28
+ xxl: heightDP(3.8),
29
+ xl: heightDP(3.2),
30
+ l: heightDP(2.8),
31
+ m: heightDP(2.3),
32
+ s: heightDP(2),
33
+ xs: heightDP(1.5),
34
+ };
35
+
36
+ export type IconSizesType = Record<IconSizeType, number>;
37
+
38
+ /** Default icon sizes, in dp. */
39
+ export const iconSize: IconSizesType = {
40
+ xl: heightDP(5),
41
+ l: heightDP(4),
42
+ m: heightDP(3.4),
43
+ s: heightDP(2.3),
44
+ };
45
+
46
+ export type SpaceScaleType = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl';
47
+
48
+ export type SpacesType = {
49
+ horizontal: Record<SpaceScaleType, number>;
50
+ vertical: Record<SpaceScaleType, number>;
51
+ };
52
+
53
+ /** Default spacing scale, in dp. */
54
+ export const spaces: SpacesType = {
55
+ horizontal: {
56
+ xs: heightDP(1),
57
+ s: widthDP(2),
58
+ m: widthDP(3),
59
+ l: widthDP(4),
60
+ xl: widthDP(5),
61
+ xxl: widthDP(8),
62
+ },
63
+ vertical: {
64
+ xs: heightDP(0.7),
65
+ s: heightDP(1),
66
+ m: heightDP(2),
67
+ l: heightDP(3),
68
+ xl: heightDP(4),
69
+ xxl: heightDP(7),
70
+ },
71
+ };
72
+
73
+ export type BorderRadiusType = {
74
+ radius_5: number;
75
+ radius_10: number;
76
+ radius_15: number;
77
+ radius_20: number;
78
+ radius_100: number | string;
79
+ };
80
+
81
+ /** Default border radius scale. */
82
+ export const borderRadius: BorderRadiusType = {
83
+ radius_5: 5,
84
+ radius_10: 10,
85
+ radius_15: 15,
86
+ radius_20: 20,
87
+ radius_100: '100%',
88
+ };
89
+
90
+ export type PageSpacingType = {
91
+ horizontal: number;
92
+ top: number;
93
+ bottom: number;
94
+ };
95
+
96
+ /** Default page insets used by `Scaffold` and the fixed bars. */
97
+ export const pageSpacing: PageSpacingType = {
98
+ horizontal: spaces.horizontal.l,
99
+ top: spaces.vertical.l,
100
+ bottom: spaces.horizontal.l,
101
+ };
102
+
103
+ export type ThemeType = {
104
+ colors: ColorsType;
105
+ fontSizes: FontSizesType;
106
+ iconSize: IconSizesType;
107
+ spaces: SpacesType;
108
+ borderRadius: BorderRadiusType;
109
+ pageSpacing: PageSpacingType;
110
+ };
111
+
112
+ /** Everything in `ThemeType` except `colors`. */
113
+ export type ThemeTokensType = Omit<ThemeType, 'colors'>;
114
+
115
+ /**
116
+ * Defaults for the non-color token groups. `ThemeProvider` merges any override
117
+ * on top of these (there is no default palette — `colors` is required).
118
+ */
119
+ export const defaultTokens: ThemeTokensType = {
120
+ fontSizes,
121
+ iconSize,
122
+ spaces,
123
+ borderRadius,
124
+ pageSpacing,
125
+ };