@vvedantb/native-ui 0.2.1 → 0.3.13

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 (91) hide show
  1. package/README.md +171 -9
  2. package/package.json +84 -2
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PageTitle.tsx +81 -0
  25. package/src/PillTabs.tsx +28 -14
  26. package/src/Progress.tsx +67 -0
  27. package/src/SearchInput.tsx +163 -0
  28. package/src/Select.tsx +32 -6
  29. package/src/Separator.tsx +38 -0
  30. package/src/Sheet.tsx +107 -0
  31. package/src/Skeleton.tsx +25 -5
  32. package/src/Spinner.tsx +92 -0
  33. package/src/StatusDot.tsx +66 -0
  34. package/src/Switch.tsx +24 -7
  35. package/src/Tabs.tsx +190 -0
  36. package/src/Textarea.tsx +47 -0
  37. package/src/Toast.tsx +170 -0
  38. package/src/blur.ts +48 -0
  39. package/src/brand/AppIcons.tsx +106 -0
  40. package/src/brand/AppMark.tsx +83 -0
  41. package/src/brand/AppWordmark.tsx +97 -0
  42. package/src/brand/familyApps.ts +24 -0
  43. package/src/brand/index.ts +34 -0
  44. package/src/brand/svg.ts +263 -0
  45. package/src/brand/vProAppBenefits.ts +22 -0
  46. package/src/controlStyles.ts +395 -0
  47. package/src/dialogLayout.ts +20 -0
  48. package/src/entries/ambient.ts +3 -0
  49. package/src/entries/bottom-sheet.ts +2 -0
  50. package/src/entries/brand.ts +2 -0
  51. package/src/entries/button.ts +3 -0
  52. package/src/entries/date-time.ts +3 -0
  53. package/src/entries/floating.ts +24 -0
  54. package/src/entries/layout.ts +10 -0
  55. package/src/entries/marketing.ts +10 -0
  56. package/src/entries/page-title.ts +4 -0
  57. package/src/entries/primitives.ts +99 -0
  58. package/src/entries/search-input.ts +4 -0
  59. package/src/entries/tab-bar.ts +10 -0
  60. package/src/entries/theme.ts +15 -0
  61. package/src/entries/toast.ts +6 -0
  62. package/src/examples/ParityGallery.tsx +513 -0
  63. package/src/floating.ts +5 -1
  64. package/src/glassBlur.tsx +95 -0
  65. package/src/gradient.ts +21 -0
  66. package/src/haptics.ts +22 -2
  67. package/src/index.ts +101 -2
  68. package/src/initials.ts +10 -0
  69. package/src/marketing/PricingModal.tsx +410 -0
  70. package/src/marketing/UpgradeToProCard.tsx +103 -0
  71. package/src/marketing/pricing.ts +76 -0
  72. package/src/palette.ts +43 -0
  73. package/src/primitives/button.tsx +3 -1
  74. package/src/primitives/card.tsx +5 -2
  75. package/src/primitives/input.tsx +9 -2
  76. package/src/primitives/native-only-animated-view.tsx +10 -3
  77. package/src/primitives/overlay.tsx +179 -0
  78. package/src/primitives/select.tsx +25 -12
  79. package/src/primitives/switch.tsx +12 -13
  80. package/src/primitives/text.tsx +25 -8
  81. package/src/primitives/use-controllable-state.ts +24 -0
  82. package/src/settings/SettingsField.tsx +11 -2
  83. package/src/settings/SettingsSection.tsx +20 -6
  84. package/src/settings/SettingsToggleRow.tsx +7 -2
  85. package/src/tabBarRoutes.ts +52 -0
  86. package/src/toastChrome.ts +103 -0
  87. package/src/toastStore.ts +86 -0
  88. package/src/tokens.ts +19 -14
  89. package/src/typography.ts +24 -0
  90. package/src/useReduceMotion.ts +24 -0
  91. package/src/useScheme.ts +6 -0
package/src/Tabs.tsx ADDED
@@ -0,0 +1,190 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { Pressable, Text, View, type ViewProps } from "react-native";
3
+ import { cn } from "./cn";
4
+ import {
5
+ MIN_TOUCH_TARGET,
6
+ tabColors,
7
+ tabTrackStyle,
8
+ tabTriggerSize,
9
+ touchSlop,
10
+ typeStyle,
11
+ type TabsSize,
12
+ type TabsVariant,
13
+ } from "./controlStyles";
14
+ import { selectionHaptic } from "./haptics";
15
+ import { useControllableState } from "./primitives/use-controllable-state";
16
+ import { useLargerText } from "./theme";
17
+ import { useScheme } from "./useScheme";
18
+
19
+ /** `pills` (default): separate pills, the `PillTabs` look. `segmented`: one track with the active segment raised. `soft`: wrapping pills, the active one filled. `pills` and `segmented` are as wide as their tabs, at the start, as on the web. */
20
+ export type TabsListVariant = TabsVariant;
21
+
22
+ const TabsContext = createContext<{
23
+ value: string;
24
+ setValue: (value: string) => void;
25
+ } | null>(null);
26
+
27
+ const TabsListContext = createContext<{
28
+ variant: TabsListVariant;
29
+ size: TabsSize;
30
+ }>({ variant: "pills", size: "default" });
31
+
32
+ function useTabs(part: string) {
33
+ const context = useContext(TabsContext);
34
+ if (!context) throw new Error(`${part} must be inside Tabs.`);
35
+ return context;
36
+ }
37
+
38
+ export type TabsProps = ViewProps & {
39
+ /** Active tab when the parent controls it. */
40
+ value?: string;
41
+ /** Initial tab when `value` is not set. */
42
+ defaultValue?: string;
43
+ onValueChange?: (value: string) => void;
44
+ className?: string;
45
+ };
46
+
47
+ /** Composable tabs with the web part names (`Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`). For a flat options array use `PillTabs`. */
48
+ export function Tabs({
49
+ value,
50
+ defaultValue = "",
51
+ onValueChange,
52
+ className,
53
+ ...props
54
+ }: TabsProps) {
55
+ const [current, setValue] = useControllableState({
56
+ value,
57
+ defaultValue,
58
+ onChange: onValueChange,
59
+ });
60
+ return (
61
+ <TabsContext.Provider value={{ value: current, setValue }}>
62
+ <View className={cn("gap-3", className)} {...props} />
63
+ </TabsContext.Provider>
64
+ );
65
+ }
66
+
67
+ export type TabsListProps = ViewProps & {
68
+ variant?: TabsListVariant;
69
+ /** `sm` makes the triggers smaller. */
70
+ size?: TabsSize;
71
+ className?: string;
72
+ };
73
+
74
+ export function TabsList({
75
+ variant = "pills",
76
+ size = "default",
77
+ className,
78
+ style,
79
+ ...props
80
+ }: TabsListProps) {
81
+ const scheme = useScheme();
82
+ return (
83
+ <TabsListContext.Provider value={{ variant, size }}>
84
+ <View
85
+ accessibilityRole="tablist"
86
+ className={cn(
87
+ "flex-row",
88
+ // The web lists: `segmented` and `pills` are `inline-flex` (as wide
89
+ // as their tabs), `soft` a full-width row of centred, wrapping pills.
90
+ // Pills wrap rather than run off a phone screen.
91
+ variant === "segmented"
92
+ ? "max-w-full gap-[3px] self-start rounded-full p-[3px]"
93
+ : variant === "soft"
94
+ ? "flex-wrap items-center justify-center gap-1.5"
95
+ : "max-w-full flex-wrap gap-1.5 self-start",
96
+ className,
97
+ )}
98
+ style={variant === "segmented" ? [tabTrackStyle(scheme), style] : style}
99
+ {...props}
100
+ />
101
+ </TabsListContext.Provider>
102
+ );
103
+ }
104
+
105
+ export type TabsTriggerProps = {
106
+ value: string;
107
+ children: ReactNode;
108
+ disabled?: boolean;
109
+ className?: string;
110
+ testID?: string;
111
+ accessibilityLabel?: string;
112
+ };
113
+
114
+ export function TabsTrigger({
115
+ value,
116
+ children,
117
+ disabled,
118
+ className,
119
+ testID,
120
+ accessibilityLabel,
121
+ }: TabsTriggerProps) {
122
+ const { value: current, setValue } = useTabs("TabsTrigger");
123
+ const { variant, size } = useContext(TabsListContext);
124
+ const largerText = useLargerText();
125
+ const scheme = useScheme();
126
+ const selected = current === value;
127
+ // Fill and label are styles from `tabColors`: apps' `bg-secondary` and
128
+ // `bg-surface` can stay light in dark mode, under a light label.
129
+ const colors = tabColors(variant, selected, scheme);
130
+ const trigger = tabTriggerSize(variant, size, largerText);
131
+ // The web `.t-tab` is `font-medium`; the soft trigger is regular.
132
+ const medium = variant !== "soft";
133
+
134
+ return (
135
+ <Pressable
136
+ testID={testID}
137
+ accessibilityRole="tab"
138
+ accessibilityLabel={
139
+ accessibilityLabel ?? (typeof children === "string" ? children : undefined)
140
+ }
141
+ accessibilityState={{ selected, disabled: Boolean(disabled) }}
142
+ // The web height (30) on screen, a 44 target to touch.
143
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
144
+ disabled={disabled}
145
+ onPress={() => {
146
+ if (selected) return;
147
+ selectionHaptic();
148
+ setValue(value);
149
+ }}
150
+ className={cn(
151
+ "shrink items-center justify-center rounded-full",
152
+ trigger.className,
153
+ variant === "pills" && "border border-white/40 dark:border-white/10",
154
+ disabled ? "opacity-50" : "active:opacity-70",
155
+ className,
156
+ )}
157
+ // A plain object: NativeWind puts the class styles and `style` in an
158
+ // array, where a `({ pressed }) => ...` style is dropped.
159
+ style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
160
+ >
161
+ {typeof children === "string" || typeof children === "number" ? (
162
+ <Text
163
+ numberOfLines={1}
164
+ className={cn(
165
+ "text-center",
166
+ medium ? "font-instrument-sans-medium" : "font-instrument-sans",
167
+ trigger.textClassName,
168
+ )}
169
+ style={[{ color: colors.color }, medium ? typeStyle("medium") : null]}
170
+ >
171
+ {children}
172
+ </Text>
173
+ ) : (
174
+ children
175
+ )}
176
+ </Pressable>
177
+ );
178
+ }
179
+
180
+ export type TabsContentProps = ViewProps & {
181
+ value: string;
182
+ className?: string;
183
+ };
184
+
185
+ /** Renders only while its tab is active. */
186
+ export function TabsContent({ value, className, ...props }: TabsContentProps) {
187
+ const { value: current } = useTabs("TabsContent");
188
+ if (current !== value) return null;
189
+ return <View className={className} {...props} />;
190
+ }
@@ -0,0 +1,47 @@
1
+ import * as React from "react";
2
+ import { Input as PrimitiveInput } from "./primitives/input";
3
+ import { cn } from "./cn";
4
+ import { fieldStyle, fieldTextStyle, typeStyle } from "./controlStyles";
5
+ import { palette } from "./palette";
6
+ import { useLargerText } from "./theme";
7
+ import { useScheme } from "./useScheme";
8
+
9
+ type TextareaProps = React.ComponentPropsWithoutRef<typeof PrimitiveInput> & {
10
+ className?: string;
11
+ };
12
+
13
+ /** Multiline `Input`: same fill, border, radius and type. Grows from 4 lines. */
14
+ const Textarea = React.forwardRef<
15
+ React.ElementRef<typeof PrimitiveInput>,
16
+ TextareaProps
17
+ >(({ className, placeholderTextColor, numberOfLines = 4, style, ...props }, ref) => {
18
+ const largerText = useLargerText();
19
+ const scheme = useScheme();
20
+ return (
21
+ <PrimitiveInput
22
+ ref={ref}
23
+ multiline
24
+ numberOfLines={numberOfLines}
25
+ textAlignVertical="top"
26
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
27
+ className={cn(
28
+ // The web textarea: min-h-20 px-3.5 py-2.5, 16 px type.
29
+ "min-h-20 border px-3.5 py-2.5 text-base font-instrument-sans",
30
+ largerText && "min-h-32 px-4 py-3 text-xl font-instrument-sans-medium",
31
+ className,
32
+ )}
33
+ style={[
34
+ fieldStyle(scheme),
35
+ fieldTextStyle(scheme),
36
+ largerText ? typeStyle("medium") : null,
37
+ style,
38
+ ]}
39
+ {...props}
40
+ />
41
+ );
42
+ });
43
+
44
+ Textarea.displayName = "Textarea";
45
+
46
+ export { Textarea };
47
+ export type { TextareaProps };
package/src/Toast.tsx ADDED
@@ -0,0 +1,170 @@
1
+ import { Fragment, useEffect, useSyncExternalStore } from "react";
2
+ import {
3
+ AccessibilityInfo,
4
+ ActivityIndicator,
5
+ Platform,
6
+ Pressable,
7
+ Text,
8
+ View,
9
+ } from "react-native";
10
+ import { FadeInUp, FadeOutUp, LinearTransition } from "react-native-reanimated";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
12
+ import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
13
+ import Svg, { Path } from "react-native-svg";
14
+ import { cn } from "./cn";
15
+ import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
16
+ import { FLOATING_INSET } from "./floating";
17
+ import {
18
+ TOAST_ICON_SIZE,
19
+ TOAST_ICONS,
20
+ toastIconColor,
21
+ toastShadowStyle,
22
+ toastSurfaceStyle,
23
+ } from "./toastChrome";
24
+ import { tokens } from "./tokens";
25
+ import { useLargerText } from "./theme";
26
+ import { useScheme } from "./useScheme";
27
+ import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
28
+ import { getToasts, subscribeToasts, toast, type ToastItem } from "./toastStore";
29
+
30
+ // On iOS the overlay draws above RN `Modal`s, so toasts show over dialogs (as
31
+ // sonner does on the web). On Android a `Modal` still covers them.
32
+ const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : Fragment;
33
+
34
+ const toastShadow = toastShadowStyle(Platform.OS);
35
+
36
+ /**
37
+ * Renders the `toast()` queue at the top of the screen, inside the safe-area
38
+ * insets. Mount once, inside `SafeAreaProvider` and after the navigator.
39
+ */
40
+ export function Toaster({ offset = 8 }: { offset?: number }) {
41
+ const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts);
42
+ const insets = useSafeAreaInsets();
43
+ if (items.length === 0) return null;
44
+ return (
45
+ <FullWindowOverlay>
46
+ <View
47
+ pointerEvents="box-none"
48
+ className="gap-2"
49
+ style={{
50
+ position: "absolute",
51
+ top: insets.top + offset,
52
+ left: insets.left + FLOATING_INSET,
53
+ right: insets.right + FLOATING_INSET,
54
+ zIndex: 50,
55
+ }}
56
+ >
57
+ {items.map((item) => (
58
+ <ToastCard key={item.id} item={item} />
59
+ ))}
60
+ </View>
61
+ </FullWindowOverlay>
62
+ );
63
+ }
64
+
65
+ /** The web filled 24 icons (`toastChrome`), or a spinner while loading. */
66
+ function ToastIcon({ item }: { item: ToastItem }) {
67
+ const scheme = useScheme();
68
+ if (item.variant === "loading") {
69
+ return <ActivityIndicator size="small" color={tokens.color.muted[scheme]} />;
70
+ }
71
+ if (item.variant === "default") return null;
72
+ const { viewBox, d, evenOdd } = TOAST_ICONS[item.variant];
73
+ const rule = evenOdd ? "evenodd" : undefined;
74
+ return (
75
+ <Svg width={TOAST_ICON_SIZE} height={TOAST_ICON_SIZE} viewBox={viewBox}>
76
+ <Path
77
+ d={d}
78
+ fill={toastIconColor(item.variant, scheme)}
79
+ fillRule={rule}
80
+ clipRule={rule}
81
+ />
82
+ </Svg>
83
+ );
84
+ }
85
+
86
+ function ToastCard({ item }: { item: ToastItem }) {
87
+ const largerText = useLargerText();
88
+ const scheme = useScheme();
89
+
90
+ useEffect(() => {
91
+ if (!Number.isFinite(item.duration)) return;
92
+ const timer = setTimeout(() => toast.dismiss(item.id), item.duration);
93
+ return () => clearTimeout(timer);
94
+ }, [item]);
95
+
96
+ useEffect(() => {
97
+ AccessibilityInfo.announceForAccessibility(
98
+ item.description ? `${item.title}. ${item.description}` : item.title,
99
+ );
100
+ }, [item.title, item.description]);
101
+
102
+ return (
103
+ <NativeOnlyAnimatedView
104
+ entering={FadeInUp.duration(200)}
105
+ exiting={FadeOutUp.duration(160)}
106
+ layout={LinearTransition}
107
+ >
108
+ {/* The web toast: flat overlay fill, radius 16, ring and a light shadow. */}
109
+ <View style={[toastSurfaceStyle(scheme), toastShadow]}>
110
+ <Pressable
111
+ testID={`toast-${item.id}`}
112
+ // With an action, the text and the action button stay separate
113
+ // elements for VoiceOver and TalkBack instead of one grouped button.
114
+ accessible={!item.action}
115
+ accessibilityRole="button"
116
+ accessibilityHint="Closes the message"
117
+ onPress={() => toast.dismiss(item.id)}
118
+ className="flex-row items-center gap-3 px-4 py-3"
119
+ >
120
+ <ToastIcon item={item} />
121
+ <View className="min-w-0 flex-1">
122
+ {/* Web `text-sm font-medium leading-snug` (14 / 19.25, 500); `text-lg` with larger text. */}
123
+ <Text
124
+ className={cn(
125
+ "font-instrument-sans-medium",
126
+ largerText ? "text-lg" : "text-sm",
127
+ )}
128
+ style={[
129
+ textStyle("foreground", scheme),
130
+ typeStyle("medium"),
131
+ largerText ? sizeStyle("text-lg") : sizeStyle("text-sm", "snug"),
132
+ ]}
133
+ >
134
+ {item.title}
135
+ </Text>
136
+ {item.description ? (
137
+ <Text
138
+ className={cn(
139
+ "font-instrument-sans",
140
+ largerText ? "text-base" : "text-sm",
141
+ )}
142
+ style={textStyle("muted", scheme)}
143
+ >
144
+ {item.description}
145
+ </Text>
146
+ ) : null}
147
+ </View>
148
+ {item.action ? (
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ hitSlop={8}
152
+ onPress={() => {
153
+ item.action?.onPress();
154
+ toast.dismiss(item.id);
155
+ }}
156
+ className="rounded-full bg-secondary px-3 py-1.5 active:opacity-70"
157
+ >
158
+ <Text
159
+ className="font-instrument-sans-semibold text-sm"
160
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
161
+ >
162
+ {item.action.label}
163
+ </Text>
164
+ </Pressable>
165
+ ) : null}
166
+ </Pressable>
167
+ </View>
168
+ </NativeOnlyAnimatedView>
169
+ );
170
+ }
package/src/blur.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type * as ExpoBlur from "expo-blur";
2
+
3
+ export type ExpoBlurModule = typeof ExpoBlur;
4
+
5
+ let expoBlur: ExpoBlurModule | null | undefined;
6
+
7
+ /**
8
+ * `expo-blur`, or `null` when the optional peer is not installed. Metro
9
+ * bundles a `require` placed directly in a `try` block as an optional
10
+ * dependency, so a missing peer does not break the bundle.
11
+ */
12
+ export function loadBlur(): ExpoBlurModule | null {
13
+ if (expoBlur === undefined) {
14
+ try {
15
+ expoBlur = require("expo-blur") as ExpoBlurModule;
16
+ } catch {
17
+ expoBlur = null;
18
+ }
19
+ }
20
+ return expoBlur;
21
+ }
22
+
23
+ export type GlassMode = "blur" | "backdrop" | "solid";
24
+
25
+ /**
26
+ * How a floating surface draws its glass: a native `BlurView` on iOS and
27
+ * Android, a CSS backdrop blur on web, or the solid fill when blur is off,
28
+ * Reduce Transparency is on or `expo-blur` is missing.
29
+ */
30
+ export function glassMode({
31
+ os,
32
+ blur,
33
+ reduceTransparency,
34
+ hasBlurView,
35
+ }: {
36
+ os: string;
37
+ blur: boolean;
38
+ reduceTransparency: boolean;
39
+ hasBlurView: boolean;
40
+ }): GlassMode {
41
+ if (!blur || reduceTransparency) {
42
+ return "solid";
43
+ }
44
+ if (os === "web") {
45
+ return "backdrop";
46
+ }
47
+ return (os === "ios" || os === "android") && hasBlurView ? "blur" : "solid";
48
+ }
@@ -0,0 +1,106 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import Svg, { G, Path } from "react-native-svg";
3
+ import { tokens } from "../tokens";
4
+ import { useScheme } from "../useScheme";
5
+ import {
6
+ APP_MARKS,
7
+ APP_MARK_VIEWBOX,
8
+ APP_MARK_VIEWBOX_START,
9
+ APP_NAMES,
10
+ markLinecap,
11
+ markStrokeWidth,
12
+ type AppName,
13
+ } from "./svg";
14
+
15
+ export type AppIconProps = {
16
+ /** Rendered width and height in points (the glyph is square). Default 24. */
17
+ size?: number;
18
+ /** Glyph colour, for fill and stroke glyphs. Default the scheme's `tokens.color.foreground`. */
19
+ color?: string;
20
+ /** Stroke width in rendered points, for stroke glyphs. Default: the glyph's own weight. Fill glyphs ignore it. */
21
+ stroke?: number;
22
+ /** Accessible name. Without it the icon is hidden from assistive tech. */
23
+ title?: string;
24
+ /** `"center"` (default) centres the glyph in its square. `"start"` left-aligns its ink, for vertical lists. */
25
+ align?: "center" | "start";
26
+ testID?: string;
27
+ style?: StyleProp<ViewStyle>;
28
+ };
29
+
30
+ export type AppIconComponent = ((props: AppIconProps) => React.JSX.Element) & {
31
+ displayName?: string;
32
+ };
33
+
34
+ function viewBoxSide(app: AppName): number {
35
+ return Number(APP_MARK_VIEWBOX[app].split(" ")[3]);
36
+ }
37
+
38
+ function createAppIcon(app: AppName, displayName: string): AppIconComponent {
39
+ function Icon({
40
+ size = 24,
41
+ color,
42
+ stroke,
43
+ title,
44
+ align = "center",
45
+ testID,
46
+ style,
47
+ }: AppIconProps) {
48
+ const scheme = useScheme();
49
+ const fill = color ?? tokens.color.foreground[scheme];
50
+ const { kind, paths } = APP_MARKS[app];
51
+ const strokeWidth =
52
+ stroke === undefined
53
+ ? markStrokeWidth(app)
54
+ : stroke * (viewBoxSide(app) / size);
55
+ const glyphProps =
56
+ kind === "fill"
57
+ ? { fill }
58
+ : {
59
+ fill: "none",
60
+ stroke: fill,
61
+ strokeWidth,
62
+ strokeLinecap: markLinecap(app),
63
+ strokeLinejoin: "round" as const,
64
+ };
65
+ return (
66
+ <Svg
67
+ width={size}
68
+ height={size}
69
+ viewBox={
70
+ align === "start" ? APP_MARK_VIEWBOX_START[app] : APP_MARK_VIEWBOX[app]
71
+ }
72
+ preserveAspectRatio="xMidYMid meet"
73
+ accessible={Boolean(title)}
74
+ accessibilityRole={title ? "image" : undefined}
75
+ accessibilityLabel={title}
76
+ accessibilityElementsHidden={!title}
77
+ importantForAccessibility={title ? "auto" : "no-hide-descendants"}
78
+ testID={testID}
79
+ style={style}
80
+ >
81
+ <G {...glyphProps}>
82
+ {paths.map((d) => (
83
+ <Path key={d} d={d} />
84
+ ))}
85
+ </G>
86
+ </Svg>
87
+ );
88
+ }
89
+ Icon.displayName = displayName;
90
+ return Icon;
91
+ }
92
+
93
+ const iconName = (app: AppName) =>
94
+ `Icon${app === "projectv" ? "ProjectV" : app[0]!.toUpperCase() + app.slice(1)}`;
95
+
96
+ /** Plain app glyph components by app name, for dynamic lookup. Same paths and viewBoxes as the web `APP_ICONS`. */
97
+ export const APP_ICONS = Object.fromEntries(
98
+ APP_NAMES.map((app) => [app, createAppIcon(app, iconName(app))]),
99
+ ) as Record<AppName, AppIconComponent>;
100
+
101
+ export const IconVibot = APP_ICONS.vibot;
102
+ export const IconVhub = APP_ICONS.vhub;
103
+ export const IconVmem = APP_ICONS.vmem;
104
+ export const IconVerve = APP_ICONS.verve;
105
+ export const IconVelth = APP_ICONS.velth;
106
+ export const IconProjectV = APP_ICONS.projectv;
@@ -0,0 +1,83 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import Svg, { G, Path, Rect } from "react-native-svg";
3
+ import { APP_ICONS } from "./AppIcons";
4
+ import {
5
+ APP_MARKS,
6
+ MARK_VIEWBOX,
7
+ TILE_RADIUS,
8
+ markLinecap,
9
+ markStrokeWidth,
10
+ type AppMarkVariant,
11
+ type AppName,
12
+ } from "./svg";
13
+
14
+ export type AppMarkProps = {
15
+ /** Which family app's mark to draw. */
16
+ app: AppName;
17
+ /** `"mark"` (default): the plain `APP_ICONS` glyph. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
18
+ variant?: AppMarkVariant;
19
+ /** Width and height in points. Default 24. */
20
+ size?: number;
21
+ /** Accessible name. Without it the mark is hidden from assistive tech. */
22
+ title?: string;
23
+ testID?: string;
24
+ style?: StyleProp<ViewStyle>;
25
+ };
26
+
27
+ const TILE_TONES = {
28
+ tile: { bg: "#000", fg: "#fff" },
29
+ "tile-light": { bg: "#fff", fg: "#000" },
30
+ } as const;
31
+
32
+ /**
33
+ * One of the six family app marks, as on the web `AppMark`. Tiles share the
34
+ * 1024 grid with the plain mark, so a bare glyph matches the tile glyph.
35
+ * Prefer `APP_ICONS[app]` for plain marks; tiles are for app icons only.
36
+ */
37
+ export function AppMark({
38
+ app,
39
+ variant = "mark",
40
+ size = 24,
41
+ title,
42
+ testID,
43
+ style,
44
+ }: AppMarkProps) {
45
+ if (variant === "mark") {
46
+ const Icon = APP_ICONS[app];
47
+ return <Icon size={size} title={title} testID={testID} style={style} />;
48
+ }
49
+ const { kind, paths } = APP_MARKS[app];
50
+ const tone = TILE_TONES[variant];
51
+ const glyphProps =
52
+ kind === "fill"
53
+ ? { fill: tone.fg }
54
+ : {
55
+ fill: "none",
56
+ stroke: tone.fg,
57
+ strokeWidth: markStrokeWidth(app),
58
+ strokeLinecap: markLinecap(app),
59
+ strokeLinejoin: "round" as const,
60
+ };
61
+ return (
62
+ <Svg
63
+ width={size}
64
+ height={size}
65
+ viewBox={MARK_VIEWBOX}
66
+ preserveAspectRatio="xMidYMid meet"
67
+ accessible={Boolean(title)}
68
+ accessibilityRole={title ? "image" : undefined}
69
+ accessibilityLabel={title}
70
+ accessibilityElementsHidden={!title}
71
+ importantForAccessibility={title ? "auto" : "no-hide-descendants"}
72
+ testID={testID}
73
+ style={style}
74
+ >
75
+ <Rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} />
76
+ <G {...glyphProps}>
77
+ {paths.map((d) => (
78
+ <Path key={d} d={d} />
79
+ ))}
80
+ </G>
81
+ </Svg>
82
+ );
83
+ }