react-native-terra-ui 0.9.1 → 0.10.1

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 (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/lib/module/components/avatar/Avatar.js +7 -2
  3. package/lib/module/components/avatar/Avatar.js.map +1 -1
  4. package/lib/module/components/header/header-buttons.js +1 -1
  5. package/lib/module/components/header/header-buttons.js.map +1 -1
  6. package/lib/module/components/icon/Icon.js +2 -2
  7. package/lib/module/components/toast/Toast.js +1 -1
  8. package/lib/module/components/toast/Toast.js.map +1 -1
  9. package/lib/module/components/toast/ToastProvider.js +9 -236
  10. package/lib/module/components/toast/ToastProvider.js.map +1 -1
  11. package/lib/module/components/toast/hooks/use-toast-manager.js +96 -0
  12. package/lib/module/components/toast/hooks/use-toast-manager.js.map +1 -0
  13. package/lib/module/components/toast/parts/ToastAnimatedItem.js +34 -50
  14. package/lib/module/components/toast/parts/ToastAnimatedItem.js.map +1 -1
  15. package/lib/module/components/toast/parts/ToastOverlay.ios.js +36 -0
  16. package/lib/module/components/toast/parts/ToastOverlay.ios.js.map +1 -0
  17. package/lib/module/components/toast/parts/ToastOverlay.js +34 -0
  18. package/lib/module/components/toast/parts/ToastOverlay.js.map +1 -0
  19. package/lib/module/components/toast/parts/ToastViewport.js +91 -0
  20. package/lib/module/components/toast/parts/ToastViewport.js.map +1 -0
  21. package/lib/module/theme/defaults/icons.js +26 -19
  22. package/lib/module/theme/defaults/icons.js.map +1 -1
  23. package/lib/typescript/src/components/avatar/Avatar.d.ts.map +1 -1
  24. package/lib/typescript/src/components/icon/Icon.d.ts +2 -2
  25. package/lib/typescript/src/components/toast/ToastProvider.d.ts.map +1 -1
  26. package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts +6 -0
  27. package/lib/typescript/src/components/toast/hooks/use-toast-manager.d.ts.map +1 -0
  28. package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts +3 -2
  29. package/lib/typescript/src/components/toast/parts/ToastAnimatedItem.d.ts.map +1 -1
  30. package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts +28 -0
  31. package/lib/typescript/src/components/toast/parts/ToastOverlay.d.ts.map +1 -0
  32. package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts +28 -0
  33. package/lib/typescript/src/components/toast/parts/ToastOverlay.ios.d.ts.map +1 -0
  34. package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts +11 -0
  35. package/lib/typescript/src/components/toast/parts/ToastViewport.d.ts.map +1 -0
  36. package/lib/typescript/src/theme/defaults/icons.d.ts.map +1 -1
  37. package/lib/typescript/src/theme/types.d.ts +1 -1
  38. package/lib/typescript/src/theme/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/avatar/Avatar.tsx +6 -1
  41. package/src/components/header/header-buttons.tsx +1 -1
  42. package/src/components/icon/Icon.tsx +2 -2
  43. package/src/components/toast/Toast.tsx +1 -1
  44. package/src/components/toast/ToastProvider.tsx +7 -288
  45. package/src/components/toast/hooks/use-toast-manager.tsx +114 -0
  46. package/src/components/toast/parts/ToastAnimatedItem.tsx +32 -50
  47. package/src/components/toast/parts/ToastOverlay.ios.tsx +30 -0
  48. package/src/components/toast/parts/ToastOverlay.tsx +28 -0
  49. package/src/components/toast/parts/ToastViewport.tsx +104 -0
  50. package/src/theme/defaults/icons.tsx +22 -15
  51. package/src/theme/types.ts +6 -5
@@ -1,16 +1,15 @@
1
1
  import type { ReactElement } from "react";
2
- import { StyleSheet, View } from "react-native";
2
+ import { View } from "react-native";
3
3
 
4
4
  import Animated, {
5
5
  Easing,
6
- Extrapolation,
7
- interpolate,
8
6
  Keyframe,
9
7
  type SharedValue,
10
8
  useAnimatedStyle,
11
9
  withSpring,
12
10
  withTiming,
13
11
  } from "react-native-reanimated";
12
+ import { StyleSheet } from "react-native-unistyles";
14
13
 
15
14
  import type { ToastId, ToastPlacement } from "../types";
16
15
 
@@ -21,7 +20,8 @@ export interface ToastAnimatedItemProps {
21
20
  index: number;
22
21
  maxVisibleToasts: number;
23
22
  placement: ToastPlacement;
24
- total: SharedValue<number>;
23
+ total: number;
24
+ frontId: ToastId;
25
25
  }
26
26
 
27
27
  export const enteringTop = new Keyframe({
@@ -72,12 +72,7 @@ export const exitingBottom = new Keyframe({
72
72
  },
73
73
  }).duration(150);
74
74
 
75
- const opacityValue = [1, 0] as [number, number];
76
- const opacityTimingConfig = { duration: 300 };
77
- const translateYValue = [0, 10] as [number, number];
78
- const translateYTimingConfig = { duration: 300 };
79
- const scaleValue = [1, 0.97] as [number, number];
80
- const scaleTimingConfig = { duration: 300 };
75
+ const timingConfig = { duration: 300 };
81
76
 
82
77
  export function ToastAnimatedItem({
83
78
  children,
@@ -87,28 +82,14 @@ export function ToastAnimatedItem({
87
82
  maxVisibleToasts,
88
83
  placement,
89
84
  total,
85
+ frontId,
90
86
  }: ToastAnimatedItemProps) {
87
+ const depth = total - index - 1;
88
+ const isVisible = depth < maxVisibleToasts;
91
89
  const containerStyle = useAnimatedStyle(() => {
92
- const heightEntries = Object.entries(heights.value);
93
- const lastToastId = heightEntries[heightEntries.length - 1]?.[0];
94
- const lastToastHeight = lastToastId ? heights.value[lastToastId] : undefined;
95
- const totalValue = total.value;
90
+ const lastToastHeight = heights.value[frontId];
96
91
  const sign = placement === "top" ? 1 : -1;
97
92
 
98
- const inputRange = [totalValue - 1, totalValue - 2];
99
- const opacityInputRange = [totalValue - maxVisibleToasts, totalValue - maxVisibleToasts - 1];
100
-
101
- const opacity = interpolate(index, opacityInputRange, opacityValue);
102
- const translateY = interpolate(
103
- index,
104
- inputRange,
105
- [translateYValue[0], translateYValue[1] * sign],
106
- Extrapolation.CLAMP
107
- );
108
- const scale = interpolate(index, inputRange, scaleValue, {
109
- extrapolateLeft: Extrapolation.CLAMP,
110
- });
111
-
112
93
  return {
113
94
  height: lastToastHeight
114
95
  ? withSpring(lastToastHeight, {
@@ -117,11 +98,10 @@ export function ToastAnimatedItem({
117
98
  mass: 3,
118
99
  })
119
100
  : undefined,
120
- pointerEvents: opacity === 0 ? "none" : "auto",
121
- opacity: withTiming(opacity, opacityTimingConfig),
101
+ opacity: withTiming(isVisible ? 1 : 0, timingConfig),
122
102
  transform: [
123
- { translateY: withTiming(translateY, translateYTimingConfig) },
124
- { scale: withTiming(scale, scaleTimingConfig) },
103
+ { translateY: withTiming(Math.min(depth, 1) * 10 * sign, timingConfig) },
104
+ { scale: withTiming(Math.max(0, 1 - depth * 0.03), timingConfig) },
125
105
  ],
126
106
  zIndex: 1000 + index,
127
107
  };
@@ -134,23 +114,26 @@ export function ToastAnimatedItem({
134
114
  pointerEvents="box-none"
135
115
  style={[styles.position, placement === "top" ? styles.top : styles.bottom]}
136
116
  >
137
- <Animated.View style={containerStyle}>{children}</Animated.View>
138
- <View
139
- pointerEvents="none"
140
- aria-hidden
141
- importantForAccessibility="no-hide-descendants"
142
- accessibilityElementsHidden
143
- style={styles.measurement}
144
- onLayout={(event) => {
145
- const measuredHeight = event.nativeEvent.layout.height;
146
- heights.value = {
147
- ...heights.value,
148
- [id]: measuredHeight,
149
- };
150
- }}
117
+ <Animated.View
118
+ style={containerStyle}
119
+ pointerEvents={isVisible ? "box-none" : "none"}
120
+ accessibilityElementsHidden={!isVisible}
121
+ importantForAccessibility={isVisible ? "auto" : "no-hide-descendants"}
122
+ aria-hidden={!isVisible}
151
123
  >
152
- {process.env.NODE_ENV === "test" ? null : children}
153
- </View>
124
+ {/* An unconstrained child measures natural height without mounting a second copy. */}
125
+ <View
126
+ style={[styles.content, placement === "top" ? styles.top : styles.bottom]}
127
+ onLayout={(event) => {
128
+ const height = event.nativeEvent.layout.height;
129
+ if (heights.value[id] !== height) {
130
+ heights.value = { ...heights.value, [id]: height };
131
+ }
132
+ }}
133
+ >
134
+ {children}
135
+ </View>
136
+ </Animated.View>
154
137
  </Animated.View>
155
138
  );
156
139
  }
@@ -167,10 +150,9 @@ const styles = StyleSheet.create({
167
150
  bottom: {
168
151
  bottom: 0,
169
152
  },
170
- measurement: {
153
+ content: {
171
154
  position: "absolute",
172
155
  left: 0,
173
156
  right: 0,
174
- opacity: 0,
175
157
  },
176
158
  });
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { FullWindowOverlay } from "react-native-screens";
4
+
5
+ /**
6
+ * Lifts the toast viewport into a window of its own, above every presented
7
+ * view controller.
8
+ *
9
+ * Without this, a toast raised from a screen presented as `modal` or
10
+ * `formSheet` is invisible. `ToastProvider` sits above the app's navigator, so
11
+ * its viewport belongs to the root view controller's view; UIKit presents a
12
+ * sheet as a *separate* view controller on top of that one. They are different
13
+ * presentation layers, so no `zIndex` or `elevation` can lift the viewport over
14
+ * the sheet — the toast is mounted and animating, just underneath. The reader
15
+ * presses something, the app reports the result, and nothing appears.
16
+ *
17
+ * `FullWindowOverlay` hosts its children in its own `UIWindow`, which sits
18
+ * above presented controllers, so the viewport paints over a sheet the same way
19
+ * it paints over an ordinary screen.
20
+ *
21
+ * It covers the whole window, but it does not swallow the app's touches:
22
+ * `RNSFullWindowOverlay`'s `pointInside:` walks its subviews and answers NO
23
+ * anywhere they are not, so presses fall through to whatever is underneath.
24
+ *
25
+ * Mounted only alongside a non-empty viewport — `ToastViewport` returns `null`
26
+ * before reaching this — so an idle app carries no extra window.
27
+ */
28
+ export function ToastOverlay({ children }: { children: ReactNode }) {
29
+ return <FullWindowOverlay>{children}</FullWindowOverlay>;
30
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Hosts the toast viewport where it already is, because on Android it is
5
+ * already in the right place.
6
+ *
7
+ * The iOS variant explains the problem: a sheet there is a separate view
8
+ * controller, so a toast in the root tree paints underneath it. Android does
9
+ * not have that problem for these screens. `react-native-screens` handles
10
+ * `formSheet` as a `StackPresentation` inside the screen stack (see
11
+ * `Screen.kt`), which lives in the Activity's own window — the same window as
12
+ * this provider — so the viewport's `elevation: 9999` puts it on top the
13
+ * ordinary way. Verified on an API 36 emulator: a toast raised from a
14
+ * `formSheet` route renders over that sheet.
15
+ *
16
+ * Not to be confused with `ScreenModalFragment`, which *is* a
17
+ * `BottomSheetDialogFragment` and so does carry its own `Window`. It backs a
18
+ * different presentation, not the `formSheet` route above. A toast raised from
19
+ * something hosted in a real `Dialog` — that fragment, or React Native's own
20
+ * `Modal` — would still be behind it, and `elevation` cannot cross windows.
21
+ * Untested, and left alone: `FullWindowOverlay` is iOS-only (elsewhere it warns
22
+ * and renders a plain `View`), so the remedy there would be to own a window
23
+ * too, via a React Native `Modal`, which brings its own back-button and
24
+ * touch-capture behaviour.
25
+ */
26
+ export function ToastOverlay({ children }: { children: ReactNode }) {
27
+ return <>{children}</>;
28
+ }
@@ -0,0 +1,104 @@
1
+ import { useEffect } from "react";
2
+ import { Keyboard, Platform } from "react-native";
3
+
4
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
5
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
6
+
7
+ import type { ToastId, ToastItem, ToastManager, ToastPlacement, ToastProviderProps } from "../types";
8
+ import { ToastAnimatedItem } from "./ToastAnimatedItem";
9
+ import { ToastOverlay } from "./ToastOverlay";
10
+
11
+ interface ToastViewportProps {
12
+ placement: ToastPlacement;
13
+ toasts: ToastItem[];
14
+ manager: ToastManager;
15
+ maxVisibleToasts: number;
16
+ offset?: ToastProviderProps["offset"];
17
+ }
18
+
19
+ export function ToastViewport({ placement, toasts, manager, maxVisibleToasts, offset }: ToastViewportProps) {
20
+ const safeAreaInsets = useSafeAreaInsets();
21
+ const heights = useSharedValue<Record<ToastId, number>>({});
22
+ const keyboardHeight = useSharedValue(0);
23
+
24
+ const baseBottom = offset?.bottom ?? safeAreaInsets.bottom + (Platform.OS === "ios" ? 6 : 12);
25
+ const baseTop = offset?.top ?? safeAreaInsets.top + (Platform.OS === "ios" ? 0 : 12);
26
+
27
+ useEffect(() => {
28
+ if (placement !== "bottom") return;
29
+ const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
30
+ const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
31
+ const showSub = Keyboard.addListener(showEvent, (e) => {
32
+ keyboardHeight.value = withTiming(e.endCoordinates.height);
33
+ });
34
+ const hideSub = Keyboard.addListener(hideEvent, () => {
35
+ keyboardHeight.value = withTiming(0);
36
+ });
37
+ return () => {
38
+ showSub.remove();
39
+ hideSub.remove();
40
+ };
41
+ }, [keyboardHeight, placement]);
42
+
43
+ const gap = Platform.OS === "ios" ? 6 : 12;
44
+
45
+ const keyboardStyle = useAnimatedStyle(() => {
46
+ if (placement === "top") return { top: baseTop };
47
+ if (keyboardHeight.value > 0) {
48
+ return { bottom: keyboardHeight.value + gap };
49
+ }
50
+ return { bottom: baseBottom };
51
+ });
52
+
53
+ useEffect(() => {
54
+ const activeIds = new Set(toasts.map((toast) => toast.id));
55
+ const nextHeights: Record<ToastId, number> = {};
56
+ for (const [id, height] of Object.entries(heights.value)) {
57
+ if (activeIds.has(id)) {
58
+ nextHeights[id] = height;
59
+ }
60
+ }
61
+ heights.value = nextHeights;
62
+ }, [heights, toasts]);
63
+
64
+ const frontToast = toasts[toasts.length - 1];
65
+ if (!frontToast) return null;
66
+
67
+ return (
68
+ <ToastOverlay>
69
+ <Animated.View
70
+ pointerEvents="box-none"
71
+ style={[
72
+ {
73
+ position: "absolute",
74
+ zIndex: 9999,
75
+ elevation: 9999,
76
+ left: offset?.left ?? safeAreaInsets.left + 12,
77
+ right: offset?.right ?? safeAreaInsets.right + 12,
78
+ },
79
+ keyboardStyle,
80
+ ]}
81
+ >
82
+ {toasts.map((toast, index) => (
83
+ <ToastAnimatedItem
84
+ key={toast.id}
85
+ heights={heights}
86
+ id={toast.id}
87
+ index={index}
88
+ maxVisibleToasts={maxVisibleToasts}
89
+ placement={placement}
90
+ total={toasts.length}
91
+ frontId={frontToast.id}
92
+ >
93
+ {toast.component({
94
+ id: toast.id,
95
+ index,
96
+ hide: manager.hide,
97
+ show: manager.show,
98
+ })}
99
+ </ToastAnimatedItem>
100
+ ))}
101
+ </Animated.View>
102
+ </ToastOverlay>
103
+ );
104
+ }
@@ -19,6 +19,11 @@ const strokeProps = (color: string, strokeWidth: number) => ({
19
19
  strokeLinejoin: "round" as const,
20
20
  });
21
21
 
22
+ // Every registry icon draws through this: one 24×24 viewBox rendered at exactly
23
+ // the requested `size`, so `<Icon size={24} />` is the same optical weight
24
+ // whichever name it resolves to. A glyph that needs to sit smaller than its box
25
+ // (Avatar's person fallback) asks for a smaller `size` — it must not shrink
26
+ // itself here, or it would scale differently from every other icon.
22
27
  const SvgIcon = ({ size, children }: { size: number; children: React.ReactNode }) => (
23
28
  <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
24
29
  {children}
@@ -36,25 +41,25 @@ const PathIcon = ({ d, ...props }: TerraIconProps & { d: string }) => {
36
41
  };
37
42
 
38
43
  // Lucide chevron geometry scaled 20% from the 24×24 viewBox center (45° arms unchanged).
39
- const NAV_CENTER = 12;
40
- const NAV_CHEVRON_OPEN = 3 * 1.2;
41
- const NAV_CHEVRON_ARM = 6 * 1.2;
44
+ const CHEVRON_CENTER = 12;
45
+ const CHEVRON_OPEN = 3 * 1.2;
46
+ const CHEVRON_ARM = 6 * 1.2;
42
47
 
43
- const BackIcon: TerraIconComponent = (props) => (
48
+ const ChevronLeftIcon: TerraIconComponent = (props) => (
44
49
  <PathIcon
45
- d={`M${NAV_CENTER + NAV_CHEVRON_OPEN} ${NAV_CENTER + NAV_CHEVRON_ARM}L${NAV_CENTER - NAV_CHEVRON_OPEN} ${NAV_CENTER} ${NAV_CENTER + NAV_CHEVRON_OPEN} ${NAV_CENTER - NAV_CHEVRON_ARM}`}
50
+ d={`M${CHEVRON_CENTER + CHEVRON_OPEN} ${CHEVRON_CENTER + CHEVRON_ARM}L${CHEVRON_CENTER - CHEVRON_OPEN} ${CHEVRON_CENTER} ${CHEVRON_CENTER + CHEVRON_OPEN} ${CHEVRON_CENTER - CHEVRON_ARM}`}
46
51
  {...props}
47
52
  />
48
53
  );
49
54
 
50
- const ForwardIcon: TerraIconComponent = (props) => (
55
+ const ChevronRightIcon: TerraIconComponent = (props) => (
51
56
  <PathIcon
52
- d={`M${NAV_CENTER - NAV_CHEVRON_OPEN} ${NAV_CENTER + NAV_CHEVRON_ARM}L${NAV_CENTER + NAV_CHEVRON_OPEN} ${NAV_CENTER} ${NAV_CENTER - NAV_CHEVRON_OPEN} ${NAV_CENTER - NAV_CHEVRON_ARM}`}
57
+ d={`M${CHEVRON_CENTER - CHEVRON_OPEN} ${CHEVRON_CENTER + CHEVRON_ARM}L${CHEVRON_CENTER + CHEVRON_OPEN} ${CHEVRON_CENTER} ${CHEVRON_CENTER - CHEVRON_OPEN} ${CHEVRON_CENTER - CHEVRON_ARM}`}
53
58
  {...props}
54
59
  />
55
60
  );
56
61
 
57
- const CloseIcon: TerraIconComponent = (props) => <PathIcon d="M18 6L6 18M6 6l12 12" {...props} />;
62
+ const XIcon: TerraIconComponent = (props) => <PathIcon d="M18 6L6 18M6 6l12 12" {...props} />;
58
63
 
59
64
  const InfoIcon: TerraIconComponent = (props) => {
60
65
  const { color, size, strokeWidth } = resolveIconProps(props);
@@ -107,26 +112,28 @@ const DangerIcon: TerraIconComponent = (props) => {
107
112
  );
108
113
  };
109
114
 
115
+ const CheckIcon: TerraIconComponent = (props) => <PathIcon d="M20 6 9 17l-5-5" {...props} />;
116
+
110
117
  export const PersonIcon: TerraIconComponent = (props) => {
111
118
  const { color, size, strokeWidth } = resolveIconProps(props);
112
119
  const stroke = strokeProps(color, strokeWidth);
113
- const iconSize = size * 0.55;
114
120
 
115
121
  return (
116
- <Svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
122
+ <SvgIcon size={size}>
117
123
  <Circle cx={12} cy={8} r={4} {...stroke} />
118
124
  <Path d="M4 20c0-4 3.582-7 8-7s8 3 8 7" {...stroke} />
119
- </Svg>
125
+ </SvgIcon>
120
126
  );
121
127
  };
122
128
 
123
129
  export const defaultIcons: Record<TerraSemanticIconName, TerraIconComponent> = {
124
- "navigation.back": BackIcon,
125
- "navigation.forward": ForwardIcon,
126
- "navigation.close": CloseIcon,
130
+ "terra.chevron-left": ChevronLeftIcon,
131
+ "terra.chevron-right": ChevronRightIcon,
132
+ "terra.x": XIcon,
133
+ "terra.check": CheckIcon,
134
+ "terra.person": PersonIcon,
127
135
  "status.info": InfoIcon,
128
136
  "status.success": SuccessIcon,
129
137
  "status.warning": WarningIcon,
130
138
  "status.danger": DangerIcon,
131
- person: PersonIcon,
132
139
  };
@@ -412,14 +412,15 @@ declare global {
412
412
  export interface TerraIconRegistry extends TerraUI.IconRegistry {}
413
413
 
414
414
  export type TerraSemanticIconName =
415
- | "navigation.back"
416
- | "navigation.forward"
417
- | "navigation.close"
415
+ | "terra.chevron-left"
416
+ | "terra.chevron-right"
417
+ | "terra.x"
418
+ | "terra.check"
419
+ | "terra.person"
418
420
  | "status.info"
419
421
  | "status.success"
420
422
  | "status.warning"
421
- | "status.danger"
422
- | "person";
423
+ | "status.danger";
423
424
 
424
425
  export type TerraConfiguredIconName = keyof TerraIconRegistry & string;
425
426