@medway-ui/native 1.4.0 → 1.6.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.
@@ -0,0 +1,308 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { Modal, View, type ModalProps, type ViewProps } from "react-native";
3
+ import Svg, { Circle } from "react-native-svg";
4
+ import Animated, {
5
+ cancelAnimation,
6
+ Easing,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withRepeat,
10
+ withSequence,
11
+ withTiming,
12
+ } from "react-native-reanimated";
13
+
14
+ import { Text } from "@/components/Text";
15
+ import { useThemeColor, type ThemeColorToken } from "@/hooks/useThemeColor";
16
+ import { cn } from "@/lib/utils";
17
+
18
+ interface LoadingOverlaySpinnerProps {
19
+ /** Icon rendered at the center of the ring, e.g. `<ThemedIcon icon={Brain} />`. */
20
+ icon?: ReactNode;
21
+ /** Ring diameter in px. Defaults to 72. */
22
+ size?: number;
23
+ /** Ring stroke width in px. Defaults to 2. */
24
+ trackWidth?: number;
25
+ /** Theme token for the spinning arc. Defaults to `gray-stroke-muted`. */
26
+ colorToken?: ThemeColorToken;
27
+ /** Explicit arc color. Takes precedence over `colorToken`. */
28
+ color?: string;
29
+ /** Theme token for the track behind the arc. The track is hidden when omitted. */
30
+ trackColorToken?: ThemeColorToken;
31
+ /** Pulses the center icon opacity. Defaults to true. */
32
+ pulse?: boolean;
33
+ }
34
+
35
+ interface LoadingOverlayCardProps
36
+ extends LoadingOverlaySpinnerProps,
37
+ Omit<ViewProps, "children"> {
38
+ /** Main label. Defaults to "Carregando..."; pass `null` or `""` to hide it. */
39
+ text?: ReactNode;
40
+ /** Secondary text rendered below the label. */
41
+ description?: ReactNode;
42
+ /** Extra content below the texts, e.g. a cancel button. */
43
+ children?: ReactNode;
44
+ textClassName?: string;
45
+ descriptionClassName?: string;
46
+ }
47
+
48
+ interface LoadingOverlayProps extends LoadingOverlayCardProps {
49
+ /** Controls the overlay visibility. Defaults to true. */
50
+ visible?: boolean;
51
+ /** Called on the Android back button. Defaults to a no-op, so the overlay blocks dismissal. */
52
+ onRequestClose?: ModalProps["onRequestClose"];
53
+ /** Theme token for the backdrop color. Defaults to `backdrop`. */
54
+ overlayColorToken?: ThemeColorToken;
55
+ /** Extra classes for the backdrop. Use `overlayColorToken` to change its color. */
56
+ overlayClassName?: string;
57
+ /** Extra classes for the wrapper that constrains the card width (`w-[90%] max-w-[320px]`). */
58
+ containerClassName?: string;
59
+ /** Props forwarded to the React Native `Modal`. */
60
+ modalProps?: Omit<ModalProps, "visible" | "onRequestClose" | "children">;
61
+ }
62
+
63
+ /** Rendered only when a track token is set, so the default case skips the color lookup. */
64
+ function SpinnerTrack({
65
+ size,
66
+ radius,
67
+ strokeWidth,
68
+ colorToken,
69
+ }: {
70
+ size: number;
71
+ radius: number;
72
+ strokeWidth: number;
73
+ colorToken: ThemeColorToken;
74
+ }) {
75
+ const color = useThemeColor(colorToken);
76
+
77
+ return (
78
+ <Circle
79
+ cx={size / 2}
80
+ cy={size / 2}
81
+ r={radius}
82
+ stroke={color}
83
+ strokeWidth={strokeWidth}
84
+ fill="none"
85
+ />
86
+ );
87
+ }
88
+
89
+ function LoadingOverlaySpinner({
90
+ icon,
91
+ size = 72,
92
+ trackWidth = 2,
93
+ colorToken = "gray-stroke-muted",
94
+ color,
95
+ trackColorToken,
96
+ pulse = true,
97
+ }: LoadingOverlaySpinnerProps) {
98
+ const themeColor = useThemeColor(colorToken);
99
+ const stroke = color ?? themeColor;
100
+ const hasIcon = Boolean(icon);
101
+
102
+ const rotation = useSharedValue(0);
103
+ const opacity = useSharedValue(1);
104
+
105
+ useEffect(() => {
106
+ rotation.value = withRepeat(
107
+ withTiming(360, { duration: 1000, easing: Easing.linear }),
108
+ -1,
109
+ false
110
+ );
111
+ return () => cancelAnimation(rotation);
112
+ }, [rotation]);
113
+
114
+ useEffect(() => {
115
+ // Nothing to pulse without an icon; skip the infinite loop on the UI thread.
116
+ if (!pulse || !hasIcon) {
117
+ opacity.value = 1;
118
+ return;
119
+ }
120
+ opacity.value = 0.4;
121
+ opacity.value = withRepeat(
122
+ withSequence(
123
+ withTiming(1, { duration: 400 }),
124
+ withTiming(0.4, { duration: 400 })
125
+ ),
126
+ -1,
127
+ false
128
+ );
129
+ return () => cancelAnimation(opacity);
130
+ }, [opacity, pulse, hasIcon]);
131
+
132
+ const ringStyle = useAnimatedStyle(() => ({
133
+ transform: [{ rotate: `${rotation.value}deg` }],
134
+ }));
135
+
136
+ const iconStyle = useAnimatedStyle(() => ({
137
+ opacity: opacity.value,
138
+ }));
139
+
140
+ const radius = (size - trackWidth) / 2;
141
+ const circumference = 2 * Math.PI * radius;
142
+
143
+ return (
144
+ <View
145
+ className="items-center justify-center"
146
+ style={{ width: size, height: size }}
147
+ >
148
+ <Animated.View style={[{ position: "absolute" }, ringStyle]}>
149
+ <Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
150
+ {trackColorToken ? (
151
+ <SpinnerTrack
152
+ size={size}
153
+ radius={radius}
154
+ strokeWidth={trackWidth}
155
+ colorToken={trackColorToken}
156
+ />
157
+ ) : null}
158
+ <Circle
159
+ cx={size / 2}
160
+ cy={size / 2}
161
+ r={radius}
162
+ stroke={stroke}
163
+ strokeWidth={trackWidth}
164
+ strokeLinecap="round"
165
+ strokeDasharray={`${circumference * 0.75} ${circumference}`}
166
+ fill="none"
167
+ />
168
+ </Svg>
169
+ </Animated.View>
170
+
171
+ {icon ? <Animated.View style={iconStyle}>{icon}</Animated.View> : null}
172
+ </View>
173
+ );
174
+ }
175
+
176
+ const DEFAULT_ACCESSIBILITY_LABEL = "Carregando";
177
+
178
+ /** Builds the progressbar label from the string parts of `text` and `description`. */
179
+ function getAccessibilityLabel(text: ReactNode, description: ReactNode): string {
180
+ const parts = [text, description].filter(
181
+ (part): part is string => typeof part === "string" && part.length > 0
182
+ );
183
+ return parts.length > 0 ? parts.join(". ") : DEFAULT_ACCESSIBILITY_LABEL;
184
+ }
185
+
186
+ function LoadingOverlayCard({
187
+ icon,
188
+ size,
189
+ trackWidth,
190
+ colorToken,
191
+ color,
192
+ trackColorToken,
193
+ pulse,
194
+ text = "Carregando...",
195
+ description,
196
+ children,
197
+ className,
198
+ textClassName,
199
+ descriptionClassName,
200
+ accessibilityLabel,
201
+ ...props
202
+ }: LoadingOverlayCardProps) {
203
+ return (
204
+ <View
205
+ className={cn(
206
+ "w-full min-h-[230px] items-center justify-center gap-6 rounded-xl bg-surface-card p-10",
207
+ className
208
+ )}
209
+ {...props}
210
+ >
211
+ {/* Role and live region wrap only the spinner + texts, so `children` (e.g. a
212
+ cancel button) stay focusable and aren't re-announced by TalkBack. */}
213
+ <View
214
+ accessible
215
+ accessibilityRole="progressbar"
216
+ accessibilityLabel={
217
+ accessibilityLabel ?? getAccessibilityLabel(text, description)
218
+ }
219
+ accessibilityState={{ busy: true }}
220
+ accessibilityLiveRegion="polite"
221
+ className="w-full items-center gap-6"
222
+ >
223
+ <LoadingOverlaySpinner
224
+ icon={icon}
225
+ size={size}
226
+ trackWidth={trackWidth}
227
+ colorToken={colorToken}
228
+ color={color}
229
+ trackColorToken={trackColorToken}
230
+ pulse={pulse}
231
+ />
232
+
233
+ {text || description ? (
234
+ <View className="w-full items-center gap-2">
235
+ {text ? (
236
+ <Text
237
+ className={cn(
238
+ "text-center text-sm font-semibold text-content-title",
239
+ textClassName
240
+ )}
241
+ >
242
+ {text}
243
+ </Text>
244
+ ) : null}
245
+ {description ? (
246
+ <Text
247
+ className={cn(
248
+ "text-center text-sm font-medium text-content-paragraph",
249
+ descriptionClassName
250
+ )}
251
+ >
252
+ {description}
253
+ </Text>
254
+ ) : null}
255
+ </View>
256
+ ) : null}
257
+ </View>
258
+
259
+ {children ? <View className="w-full">{children}</View> : null}
260
+ </View>
261
+ );
262
+ }
263
+
264
+ const noop = () => {};
265
+
266
+ function LoadingOverlay({
267
+ visible = true,
268
+ onRequestClose = noop,
269
+ overlayColorToken = "backdrop",
270
+ overlayClassName,
271
+ containerClassName,
272
+ modalProps,
273
+ ...cardProps
274
+ }: LoadingOverlayProps) {
275
+ // Inline style: `bg-backdrop` depends on the consumer's Tailwind config having
276
+ // the token, which older `@medway-ui/core` versions don't ship.
277
+ const backdropColor = useThemeColor(overlayColorToken);
278
+
279
+ return (
280
+ <Modal
281
+ transparent
282
+ statusBarTranslucent
283
+ animationType="fade"
284
+ {...modalProps}
285
+ visible={visible}
286
+ onRequestClose={onRequestClose}
287
+ >
288
+ <View
289
+ className={cn(
290
+ "flex-1 items-center justify-center",
291
+ overlayClassName
292
+ )}
293
+ style={{ backgroundColor: backdropColor }}
294
+ >
295
+ <View className={cn("w-[90%] max-w-[320px]", containerClassName)}>
296
+ <LoadingOverlayCard {...cardProps} />
297
+ </View>
298
+ </View>
299
+ </Modal>
300
+ );
301
+ }
302
+
303
+ export { LoadingOverlay, LoadingOverlayCard, LoadingOverlaySpinner };
304
+ export type {
305
+ LoadingOverlayCardProps,
306
+ LoadingOverlayProps,
307
+ LoadingOverlaySpinnerProps,
308
+ };
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import { StyleSheet } from "react-native";
2
3
 
3
4
  import { Text } from "@/components/Text";
4
5
  import * as TooltipPrimitive from "@rn-primitives/tooltip";
@@ -28,7 +29,7 @@ function TooltipContent({
28
29
  }: TooltipContentProps) {
29
30
  return (
30
31
  <TooltipPrimitive.Portal>
31
- <TooltipPrimitive.Overlay className="absolute inset-0" />
32
+ <TooltipPrimitive.Overlay style={StyleSheet.absoluteFill} />
32
33
  <TooltipPrimitive.Content
33
34
  sideOffset={sideOffset}
34
35
  className={cn(
@@ -25,6 +25,9 @@ const LIGHT_COLOR_ALIAS_MAP: Record<string, string> = {
25
25
  'prev-background-fill': 'prev',
26
26
  'red-foreground': 'red-darken-2',
27
27
  'go-foreground': 'go-darken-1',
28
+ // Not a real CSS var: `--color-backdrop` resolves directly, this alias only
29
+ // keys the static fallback below (keep in sync with tokens.native.css).
30
+ backdrop: 'backdrop-light',
28
31
  };
29
32
 
30
33
  /** Fallback — dark mode (espelha tokens.css `.dark`). */
@@ -49,6 +52,8 @@ const DARK_COLOR_ALIAS_MAP: Record<string, string> = {
49
52
  'prev-background-fill': 'prev',
50
53
  'red-foreground': 'red-lighten-3',
51
54
  'go-foreground': 'go-lighten-5',
55
+ // Fallback key only, see LIGHT_COLOR_ALIAS_MAP.
56
+ backdrop: 'backdrop-dark',
52
57
  };
53
58
 
54
59
  /** Fallback estático quando NativeWind não resolve `var(--color-*)` encadeado. */
@@ -77,6 +82,8 @@ const THEME_COLOR_FALLBACKS: Record<string, string> = {
77
82
  'secondary-darken-1': 'rgba(7, 171, 149, 1)',
78
83
  'secondary-darken-3': 'rgba(13, 104, 92, 1)',
79
84
  'secondary-lighten-1': 'rgba(22, 208, 118, 1)',
85
+ 'backdrop-light': 'rgba(16, 19, 24, 0.3)',
86
+ 'backdrop-dark': 'rgba(16, 19, 24, 0.5)',
80
87
  };
81
88
 
82
89
  const DEFAULT_COLOR = '#3B4958';
package/src/index.ts CHANGED
@@ -243,6 +243,18 @@ export type { LabelProps } from "@/components/Label";
243
243
  export { Loading } from "@/components/Loading";
244
244
  export type { LoadingProps } from "@/components/Loading";
245
245
 
246
+ // LoadingOverlay
247
+ export {
248
+ LoadingOverlay,
249
+ LoadingOverlayCard,
250
+ LoadingOverlaySpinner,
251
+ } from "@/components/LoadingOverlay";
252
+ export type {
253
+ LoadingOverlayCardProps,
254
+ LoadingOverlayProps,
255
+ LoadingOverlaySpinnerProps,
256
+ } from "@/components/LoadingOverlay";
257
+
246
258
  // Skeleton
247
259
  export { Skeleton } from "@/components/Skeleton";
248
260
  export type { SkeletonProps } from "@/components/Skeleton";