@nativetail/ui 0.0.8 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativetail/ui",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {},
@@ -26,12 +26,13 @@
26
26
  "react-native": ">=0.63.0"
27
27
  },
28
28
  "dependencies": {
29
- "@gorhom/bottom-sheet": "^5.0.0-alpha.10",
29
+ "@hookform/resolvers": "^3.6.0",
30
+ "@nativetail/core": "^0.0.2",
30
31
  "@shopify/flash-list": "^1.7.0",
32
+ "countries-list": "^3.1.0",
31
33
  "expo-blur": "^13.0.2",
32
34
  "expo-linear-gradient": "~13.0.2",
33
35
  "moti": "^0.29.0",
34
- "@nativetail/core": "*",
35
36
  "react": "18.2.0",
36
37
  "react-hook-form": "^7.51.0",
37
38
  "react-native": "0.74.3",
@@ -40,8 +41,7 @@
40
41
  "react-native-reanimated": "~3.10.1",
41
42
  "react-native-safe-area-context": "4.10.1",
42
43
  "tailwind-merge": "^2.3.0",
43
- "zustand": "^4.5.2",
44
- "@hookform/resolvers": "^3.6.0"
44
+ "zustand": "^4.5.2"
45
45
  },
46
46
  "devDependencies": {
47
47
  "metro-react-native-babel-preset": "^0.77.0",
@@ -11,6 +11,7 @@ export type BottomSheetProps = ActionSheetProps & {
11
11
  indicatorClassName?: string;
12
12
  useBlur?: boolean;
13
13
  };
14
+ export type BottomSheetRef = ActionSheetRef;
14
15
  export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
15
16
  function BottomSheet(
16
17
  {
@@ -27,7 +28,7 @@ export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
27
28
  return (
28
29
  <ActionSheet
29
30
  ref={ref}
30
- containerStyle={tw.style("bg-background/95", containerClassName)}
31
+ containerStyle={tw.style("bg-background", containerClassName)}
31
32
  gestureEnabled
32
33
  indicatorStyle={tw.style("bg-muted/15", indicatorClassName)}
33
34
  {...props}
@@ -42,4 +43,3 @@ export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
42
43
  );
43
44
  }
44
45
  );
45
- export { ActionSheetRef as BottomSheetRef };
@@ -0,0 +1,141 @@
1
+ import { zodResolver } from "@hookform/resolvers/zod";
2
+ import { cn, TextInputProps, View } from "@nativetail/core";
3
+ import React, { ComponentPropsWithoutRef } from "react";
4
+ import { Control, DefaultValues, useForm } from "react-hook-form";
5
+ import { z } from "zod";
6
+ import { Button } from "../button";
7
+ import {
8
+ FloatingInput,
9
+ FloatingInputProps,
10
+ Input,
11
+ InputProps,
12
+ PhoneInput,
13
+ PinInput,
14
+ PinInputProps,
15
+ } from "../input";
16
+ import { MultiSelect, MultiSelectProps, Select, SelectProps } from "../select";
17
+
18
+ export type FormBuilderProps<
19
+ T extends z.ZodRawShape,
20
+ IValues = z.infer<z.ZodObject<T>>,
21
+ > = {
22
+ schema: z.ZodObject<T>;
23
+ inputs?: Partial<
24
+ Record<
25
+ keyof T,
26
+ {
27
+ render?: (props: {
28
+ control: Control<IValues, any>;
29
+ name: keyof T;
30
+ }) => React.ReactElement;
31
+ } & InputType
32
+ >
33
+ >;
34
+ containerClassname?: string;
35
+ submitButtonProps?: ComponentPropsWithoutRef<typeof Button>;
36
+ onSubmit?: (values: IValues) => void;
37
+ onError?: (values: Partial<Record<keyof T, any>>) => void;
38
+ isSubmitting?: boolean;
39
+ defaultValues?: DefaultValues<IValues>;
40
+ };
41
+ export function FormBuilder<T extends z.ZodRawShape>({
42
+ schema,
43
+ inputs,
44
+ containerClassname,
45
+ submitButtonProps,
46
+ onSubmit,
47
+ onError,
48
+ isSubmitting,
49
+ defaultValues,
50
+ }: FormBuilderProps<T>) {
51
+ const shape = schema.shape;
52
+ const keys = Object.keys(shape);
53
+ type FormSchemaType = z.infer<typeof schema>;
54
+ const form = useForm<FormSchemaType>({
55
+ resolver: zodResolver(schema),
56
+ defaultValues: defaultValues,
57
+ });
58
+ return (
59
+ <View className={cn("gap-2", containerClassname)}>
60
+ {keys.map((inputKey) => {
61
+ const Input = inputs[inputKey];
62
+ const Render = Input.render;
63
+ if (!Render) {
64
+ return (
65
+ <InputComponent
66
+ control={form.control}
67
+ name={inputKey}
68
+ {...Input}
69
+ key={inputKey}
70
+ />
71
+ );
72
+ }
73
+ return <Render control={form.control} name={inputKey} key={inputKey} />;
74
+ })}
75
+ <Button
76
+ className="w-full"
77
+ children={"Submit"}
78
+ {...submitButtonProps}
79
+ onPress={form.handleSubmit(onSubmit, onError)}
80
+ isLoading={isSubmitting}
81
+ />
82
+ </View>
83
+ );
84
+ }
85
+ const InputComponent = ({
86
+ control,
87
+ name,
88
+ props,
89
+ type,
90
+ }: {
91
+ control: Control<any>;
92
+ name: string;
93
+ } & InputType) => {
94
+ switch (type) {
95
+ case "text":
96
+ return <Input control={control} name={name} {...props} />;
97
+ case "floating":
98
+ return <FloatingInput control={control} name={name} {...props} />;
99
+ case "select":
100
+ return <Select control={control} name={name} {...props} />;
101
+ case "multi-select":
102
+ return <MultiSelect control={control} name={name} {...props} />;
103
+ case "pin":
104
+ return <PinInput control={control} name={name} {...props} />;
105
+ case "phone":
106
+ return <PhoneInput control={control} name={name} {...props} />;
107
+ }
108
+ return null;
109
+ };
110
+
111
+ type TextInputType = {
112
+ type: "text";
113
+ props: InputProps;
114
+ };
115
+ type FloatingInputType = {
116
+ type: "floating";
117
+ props: FloatingInputProps;
118
+ };
119
+ type SelectInputType = {
120
+ type: "select";
121
+ props: SelectProps;
122
+ };
123
+ type MultiSelectInputType = {
124
+ type: "multi-select";
125
+ props: MultiSelectProps;
126
+ };
127
+ type PinInputType = {
128
+ type: "pin";
129
+ props: PinInputProps;
130
+ };
131
+ type PhoneInputType = {
132
+ type: "phone";
133
+ props: TextInputProps;
134
+ };
135
+ type InputType =
136
+ | TextInputType
137
+ | FloatingInputType
138
+ | SelectInputType
139
+ | MultiSelectInputType
140
+ | PinInputType
141
+ | PhoneInputType;
@@ -13,3 +13,4 @@ export * from "./blur";
13
13
  export * from "./progress";
14
14
  export * from "./counter";
15
15
  export * from "./tabs";
16
+ export * from "./form-builder";
@@ -1,3 +1,4 @@
1
1
  export * from "./floating-input";
2
2
  export * from "./input";
3
3
  export * from "./pin-input";
4
+ export * from "./phone-input";
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  cn,
3
+ Pressable,
3
4
  Text,
4
5
  TextInput,
5
6
  TextInputProps,
6
7
  useTw,
7
8
  View,
8
9
  } from "@nativetail/core";
9
- import React, { LegacyRef, useState } from "react";
10
+ import { MotiView } from "moti";
11
+ import React, { createRef, useState } from "react";
10
12
  import { Control, Controller, Path } from "react-hook-form";
11
13
  import { TextInput as NativeTextInput } from "react-native";
12
14
  import ShowPassword from "./show-password";
@@ -22,7 +24,9 @@ export type InputProps<T = Record<string, any>> = TextInputProps & {
22
24
  value?: string;
23
25
  control?: Control<T, any>;
24
26
  name?: Path<T>;
25
- inputRef?: LegacyRef<NativeTextInput>;
27
+ inputRef?: React.RefObject<NativeTextInput>;
28
+ inputContainerClassName?: string;
29
+ formatter?: (value: string) => string;
26
30
  };
27
31
 
28
32
  export const Input = <T extends Record<string, any>>({
@@ -64,7 +68,9 @@ const BaseInput = <T extends Record<string, any>>({
64
68
  rightElement,
65
69
  helperText,
66
70
  leftElement,
67
- inputRef,
71
+ inputRef = createRef<NativeTextInput>(),
72
+ inputContainerClassName,
73
+ formatter,
68
74
  ...props
69
75
  }: InputProps<T>) => {
70
76
  const tw = useTw();
@@ -72,34 +78,65 @@ const BaseInput = <T extends Record<string, any>>({
72
78
  const [showPassword, setShowPassword] = useState(
73
79
  isSecretToggleable ? false : true
74
80
  );
81
+ const [isFocused, setIsFocused] = useState(false);
75
82
  return (
76
83
  <View className={cn("w-full gap-1", containerClassName)}>
77
84
  <Text className={cn("text-muted/75 duration-75 text-sm")}>{label}</Text>
78
85
 
79
- <TextInput
80
- value={value}
81
- onChangeText={onChangeText}
82
- ref={inputRef}
86
+ <View
83
87
  className={cn(
84
- "p-3 bg-card rounded-lg w-full border border-muted/15 h-12 text-foreground -z-5 text-[16px]",
85
- className,
86
- isSecretToggleable || rightElement ? "pr-12" : "",
87
- leftElement ? "pl-12" : "",
88
- error && "border-danger"
88
+ " bg-card flex-row h-12 rounded-lg w-full border border-muted/15 ",
89
+ inputContainerClassName,
90
+ error && "border-danger",
91
+ isFocused && "border-foreground"
92
+ )}
93
+ >
94
+ {leftElement}
95
+ <TextInput
96
+ value={value}
97
+ onChangeText={onChangeText}
98
+ ref={inputRef}
99
+ className={cn(
100
+ " text-foreground p-2 rounded-lg flex-1 text-[16px]",
101
+ formatter && "opacity-0 scale-0 flex-0 absolute",
102
+ className
103
+ )}
104
+ placeholderTextColor={tw.color("muted")}
105
+ secureTextEntry={!showPassword}
106
+ {...props}
107
+ onFocus={(e) => {
108
+ setIsFocused(true);
109
+ props?.onFocus?.(e);
110
+ }}
111
+ onBlur={(e) => {
112
+ setIsFocused(false);
113
+ props?.onBlur?.(e);
114
+ }}
115
+ />
116
+ {formatter && (
117
+ <Pressable
118
+ className={cn(
119
+ " text-foreground p-2 flex-row rounded-lg text-left items-center h-full flex-1 text-[16px]",
120
+ className
121
+ )}
122
+ onPress={() => {
123
+ inputRef?.current?.focus();
124
+ }}
125
+ >
126
+ {formatter(value)}
127
+ {isFocused && <InputBlink />}
128
+ {!value && (
129
+ <Text className={cn("text-muted/75 absolute left-0 p-2")}>
130
+ {props.placeholder}
131
+ </Text>
132
+ )}
133
+ </Pressable>
89
134
  )}
90
- placeholderTextColor={tw.color("muted")}
91
- secureTextEntry={!showPassword}
92
- {...props}
93
- />
94
- {helperText && <Text className="text-muted text-sm">{helperText}</Text>}
95
135
 
96
- {leftElement && (
97
- <View className="absolute left-2 bottom-2">{leftElement}</View>
98
- )}
136
+ {rightElement}
137
+ </View>
138
+ {helperText && <Text className="text-muted text-sm">{helperText}</Text>}
99
139
 
100
- {rightElement && (
101
- <View className="absolute right-2 bottom-2">{rightElement}</View>
102
- )}
103
140
  {error && <Text className="text-danger text-sm">{error}</Text>}
104
141
 
105
142
  {isSecretToggleable && (
@@ -111,3 +148,28 @@ const BaseInput = <T extends Record<string, any>>({
111
148
  </View>
112
149
  );
113
150
  };
151
+
152
+ const InputBlink = () => {
153
+ const tw = useTw();
154
+ return (
155
+ <MotiView
156
+ style={{
157
+ width: 0.1,
158
+ height: "90%",
159
+ opacity: 1,
160
+ marginHorizontal: 0.8,
161
+ backgroundColor: tw.color("foreground/85"),
162
+ }}
163
+ from={{
164
+ opacity: 0,
165
+ }}
166
+ animate={{
167
+ opacity: 1,
168
+ }}
169
+ transition={{
170
+ duration: 650,
171
+ loop: true,
172
+ }}
173
+ />
174
+ );
175
+ };
@@ -0,0 +1,242 @@
1
+ import { cn, Pressable, Text, View } from "@nativetail/core";
2
+ import { memo, useCallback, useMemo, useRef, useState } from "react";
3
+ import { Controller } from "react-hook-form";
4
+ import { FlashList, FlatList } from "react-native-actions-sheet";
5
+ import { Iconify } from "react-native-iconify";
6
+ import { BottomSheet, BottomSheetRef } from "../bottom-sheet";
7
+ import { Input, InputProps } from "./input";
8
+
9
+ import { countries, ICountry } from "countries-list";
10
+ import { FloatingInput } from "./floating-input";
11
+ type CountryType = ICountry & {
12
+ code: string;
13
+ };
14
+ export const PhoneInput = <T extends Record<string, any>>({
15
+ name,
16
+ control,
17
+ ...props
18
+ }: InputProps<T>) => {
19
+ if (control) {
20
+ return (
21
+ <Controller
22
+ name={name}
23
+ control={control}
24
+ render={({ field, fieldState }) => {
25
+ return (
26
+ <BaseInput
27
+ {...props}
28
+ value={field.value}
29
+ onChangeText={(text) => {
30
+ field.onChange(text);
31
+ }}
32
+ error={fieldState.error?.message}
33
+ />
34
+ );
35
+ }}
36
+ />
37
+ );
38
+ }
39
+ return <BaseInput {...props} />;
40
+ };
41
+
42
+ const BaseInput = <T extends Record<string, any>>(props: InputProps<T>) => {
43
+ const [selectedCountry, setSelectedCountry] = useState<CountryType>({
44
+ ...countries.US,
45
+ code: "US",
46
+ });
47
+ return (
48
+ <Input
49
+ {...props}
50
+ leftElement={
51
+ <SelectCountry
52
+ selectedCountry={selectedCountry}
53
+ setSelectedCountry={setSelectedCountry}
54
+ />
55
+ }
56
+ placeholder="(123) 456-7890"
57
+ value={props.value}
58
+ formatter={(value) => {
59
+ // Remove all non-numeric characters
60
+ const cleaned = ("" + value).replace(/\D/g, "");
61
+
62
+ // Format the cleaned number
63
+ const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
64
+ if (match) {
65
+ return `(${match[1]}) ${match[2]}-${match[3]}`;
66
+ }
67
+
68
+ return value;
69
+ }}
70
+ onChangeText={(text) => {
71
+ props.onChangeText?.(text);
72
+ }}
73
+ />
74
+ );
75
+ };
76
+
77
+ const SelectCountry = ({
78
+ selectedCountry,
79
+ setSelectedCountry,
80
+ }: {
81
+ selectedCountry: CountryType;
82
+ setSelectedCountry: (country: CountryType) => void;
83
+ }) => {
84
+ const sheetRef = useRef<BottomSheetRef>(null);
85
+ const flag = useMemo(
86
+ () => selectedCountry && getFlagEmoji(selectedCountry.code),
87
+ [selectedCountry]
88
+ );
89
+ return (
90
+ <>
91
+ <Pressable
92
+ className=" p-2 h-full items-center justify-center flex-row gap-0.5 active:opacity-75 opacity-100 border-r border-muted/15 pr-1"
93
+ onPress={() => {
94
+ sheetRef.current?.show();
95
+ }}
96
+ >
97
+ <Text>
98
+ <Text className="mr-1">{flag}</Text>+{selectedCountry.phone?.[0]}
99
+ </Text>
100
+ <Iconify icon="mingcute:down-line" size={24} color="gray" />
101
+ </Pressable>
102
+ <CountryBottomSheet
103
+ selectedCountry={selectedCountry}
104
+ setSelectedCountry={setSelectedCountry}
105
+ sheetRef={sheetRef}
106
+ />
107
+ </>
108
+ );
109
+ };
110
+
111
+ const CountryBottomSheet = ({
112
+ selectedCountry,
113
+ setSelectedCountry,
114
+ sheetRef,
115
+ }: {
116
+ selectedCountry: CountryType;
117
+ setSelectedCountry: (code: CountryType) => void;
118
+ sheetRef: React.RefObject<BottomSheetRef>;
119
+ }) => {
120
+ const [search, setSearch] = useState("");
121
+ const [open, setOpen] = useState(false);
122
+ const allCountries = useMemo(
123
+ () =>
124
+ Object.entries(countries)
125
+ .map(([code, country]) => {
126
+ return {
127
+ code,
128
+ name: country.name,
129
+ ...country,
130
+ };
131
+ })
132
+ .filter((country) =>
133
+ country.name?.toLowerCase()?.includes(search?.toLowerCase())
134
+ ),
135
+ [search]
136
+ );
137
+ return (
138
+ <BottomSheet
139
+ ref={sheetRef}
140
+ onOpen={() => setOpen(true)}
141
+ onClose={() => setOpen(false)}
142
+ contentClassName=""
143
+ >
144
+ <View className="max-w-2xl mx-auto w-full max-h-[90%]">
145
+ {open && (
146
+ <CountryList
147
+ countries={allCountries}
148
+ search={search}
149
+ setSearch={setSearch}
150
+ selectedCountry={selectedCountry}
151
+ setSelectedCountry={setSelectedCountry}
152
+ close={() => {
153
+ sheetRef.current?.hide();
154
+ }}
155
+ />
156
+ )}
157
+ </View>
158
+ </BottomSheet>
159
+ );
160
+ };
161
+ const CountryList = memo(
162
+ ({
163
+ countries,
164
+ search,
165
+ setSearch,
166
+ selectedCountry,
167
+ setSelectedCountry,
168
+ close,
169
+ }: {
170
+ countries: CountryType[];
171
+ search: string;
172
+ setSearch: (search: string) => void;
173
+
174
+ selectedCountry: CountryType;
175
+ setSelectedCountry: (code: CountryType) => void;
176
+ close: () => void;
177
+ }) => {
178
+ const renderItem = useCallback(
179
+ ({
180
+ item,
181
+ }: {
182
+ item: ICountry & {
183
+ code: string;
184
+ };
185
+ }) => {
186
+ const isActive = selectedCountry.code === item.code;
187
+ return (
188
+ <Pressable
189
+ onPress={() => {
190
+ setSelectedCountry(item);
191
+ close();
192
+ }}
193
+ className={cn(
194
+ "p-3 bg-card rounded-xl border h-12 border-muted/15 flex-row gap-4 w-full",
195
+ isActive && "bg-primary/10 border-primary"
196
+ )}
197
+ containerClassName="w-full"
198
+ >
199
+ {getFlagEmoji(item.code)}
200
+ <Text>+{item.phone?.[0]}</Text>
201
+ <Text className="font-medium">{item.name}</Text>
202
+ </Pressable>
203
+ );
204
+ },
205
+ [selectedCountry, setSelectedCountry, close]
206
+ );
207
+
208
+ return (
209
+ <View className="overflow-x-hidden h-full">
210
+ <>
211
+ <Text className="text-center text-lg font-medium mb-2">
212
+ Select Country
213
+ </Text>
214
+ <FloatingInput
215
+ value={search}
216
+ onChangeText={setSearch}
217
+ label="Search Country"
218
+ containerClassName="mb-2"
219
+ />
220
+ </>
221
+ <View>
222
+ <FlashList
223
+ data={countries.slice(0, 15)}
224
+ renderItem={renderItem}
225
+ estimatedItemSize={100}
226
+ showsVerticalScrollIndicator
227
+ showsHorizontalScrollIndicator={false}
228
+ ItemSeparatorComponent={() => <View className="h-1" />}
229
+ keyExtractor={(item) => item.code}
230
+ />
231
+ </View>
232
+ </View>
233
+ );
234
+ }
235
+ );
236
+ function getFlagEmoji(countryCode) {
237
+ const codePoints = countryCode
238
+ .toUpperCase()
239
+ .split("")
240
+ .map((char) => 127397 + char.charCodeAt());
241
+ return String.fromCodePoint(...codePoints);
242
+ }
@@ -11,7 +11,7 @@ import { memo, useCallback, useMemo } from "react";
11
11
  import { Iconify } from "react-native-iconify";
12
12
  import { Control, Controller, Path } from "react-hook-form";
13
13
 
14
- type SelectProps<T extends Record<string, any>> = PressableProps & {
14
+ export type MultiSelectProps<T = Record<string, any>> = PressableProps & {
15
15
  containerClassName?: string;
16
16
  label?: string;
17
17
  error?: string;
@@ -31,7 +31,7 @@ export const MultiSelect = <T extends Record<string, any>>({
31
31
  name,
32
32
  control,
33
33
  ...props
34
- }: SelectProps<T>) => {
34
+ }: MultiSelectProps<T>) => {
35
35
  if (control) {
36
36
  return (
37
37
  <Controller
@@ -65,7 +65,7 @@ function BaseSelect<T extends Record<string, any>>({
65
65
  placeholder,
66
66
  options,
67
67
  ...props
68
- }: SelectProps<T>) {
68
+ }: MultiSelectProps<T>) {
69
69
  const tw = useTw();
70
70
  const renderOptions = useCallback(() => {
71
71
  return options.map((option, index) => (
@@ -112,11 +112,11 @@ const SelectTrigger = memo(
112
112
  error,
113
113
  ...props
114
114
  }: PressableProps & {
115
- options: SelectProps<T>["options"];
116
- value: SelectProps<T>["value"];
117
- onChange: SelectProps<T>["onChange"];
118
- placeholder: SelectProps<T>["placeholder"];
119
- error: SelectProps<T>["error"];
115
+ options: MultiSelectProps<T>["options"];
116
+ value: MultiSelectProps<T>["value"];
117
+ onChange: MultiSelectProps<T>["onChange"];
118
+ placeholder: MultiSelectProps<T>["placeholder"];
119
+ error: MultiSelectProps<T>["error"];
120
120
  }) => {
121
121
  const selectedOptions = useMemo(
122
122
  () => options.filter((option) => value?.includes?.(option.value)),
@@ -4,7 +4,7 @@ import { memo, useCallback, useMemo } from "react";
4
4
  import { Iconify } from "react-native-iconify";
5
5
  import { Control, Controller, Path } from "react-hook-form";
6
6
 
7
- type SelectProps<T extends Record<string, any>> = PressableProps & {
7
+ export type SelectProps<T = Record<string, any>> = PressableProps & {
8
8
  containerClassName?: string;
9
9
  label?: string;
10
10
  error?: string;
@@ -1,6 +1,7 @@
1
1
  import { cn, Pressable, Text, useTw, View } from "@nativetail/core";
2
2
  import { AnimatePresence } from "moti";
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { Modal } from "react-native";
4
5
  import { Iconify } from "react-native-iconify";
5
6
  import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7
  import { create } from "zustand";
@@ -19,6 +20,8 @@ type ToastType = {
19
20
 
20
21
  containerClassName?: string;
21
22
  type?: "success" | "danger" | "info" | "warning";
23
+ modal?: boolean;
24
+ icon?: React.ReactNode;
22
25
  };
23
26
  type InsertToastType = Omit<ToastType, "id">;
24
27
  type ToastStore = {
@@ -57,7 +60,6 @@ export function Toaster() {
57
60
  const bottomToasts = toasts.filter((toast) =>
58
61
  toast.position.includes("bottom")
59
62
  );
60
-
61
63
  const safeInsets = useSafeAreaInsets();
62
64
  return (
63
65
  <AnimatePresence exitBeforeEnter presenceAffectsLayout>
@@ -93,6 +95,34 @@ const Toast = (
93
95
  toast: ToastType & {
94
96
  index: number;
95
97
  }
98
+ ) => {
99
+ const safeInsets = useSafeAreaInsets();
100
+ const [visible, setVisible] = useState(true);
101
+ const close = useCallback(() => {
102
+ setVisible(false);
103
+ }, [setVisible]);
104
+ if (toast.modal)
105
+ return (
106
+ <Modal visible={visible} onRequestClose={close} transparent>
107
+ <Pressable
108
+ className={cn(
109
+ "absolute w-full h-full bg-black/15 left-0 justify-start z-50 gap-2",
110
+ toast.position.includes("top")
111
+ ? `pt-[${safeInsets.top + 10}px]`
112
+ : `pb-[${safeInsets.bottom + 10}px]`
113
+ )}
114
+ onPress={close}
115
+ >
116
+ <BaseToast {...toast} />
117
+ </Pressable>
118
+ </Modal>
119
+ );
120
+ return <BaseToast {...toast} />;
121
+ };
122
+ const BaseToast = (
123
+ toast: ToastType & {
124
+ index: number;
125
+ }
96
126
  ) => {
97
127
  const [open, setOpen] = useState(true);
98
128
  const tw = useTw();
@@ -136,7 +166,7 @@ const Toast = (
136
166
  ),
137
167
  };
138
168
 
139
- const Icon = Icons[toast.type];
169
+ const Icon = toast.icon || Icons[toast.type];
140
170
  const horizontalPositions = {
141
171
  center: "items-center",
142
172
  left: "items-start",