@ankhorage/surface 4.3.0 → 4.4.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 (151) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +4 -4
  3. package/dist/components/tabs/Tabs.js +2 -2
  4. package/dist/components/tabs/Tabs.js.map +1 -1
  5. package/dist/components/tabs/context.js +1 -1
  6. package/dist/components/tabs/context.js.map +1 -1
  7. package/dist/context/FontContext.d.ts.map +1 -1
  8. package/dist/context/FontContext.js +3 -3
  9. package/dist/context/FontContext.js.map +1 -1
  10. package/dist/context/TranslationContext.d.ts.map +1 -1
  11. package/dist/context/TranslationContext.js +3 -4
  12. package/dist/context/TranslationContext.js.map +1 -1
  13. package/dist/core/responsive/ResponsiveProvider.d.ts.map +1 -1
  14. package/dist/core/responsive/ResponsiveProvider.js +3 -3
  15. package/dist/core/responsive/ResponsiveProvider.js.map +1 -1
  16. package/dist/examples/DocsExamples.js +1 -1
  17. package/dist/examples/DocsExamples.js.map +1 -1
  18. package/dist/features/accordion/adapters/inbound/Accordion.js +2 -2
  19. package/dist/features/accordion/adapters/inbound/Accordion.js.map +1 -1
  20. package/dist/features/accordion/adapters/inbound/AccordionContent.js +1 -1
  21. package/dist/features/accordion/adapters/inbound/AccordionContent.js.map +1 -1
  22. package/dist/features/accordion/adapters/inbound/AccordionItem.js +3 -3
  23. package/dist/features/accordion/adapters/inbound/AccordionItem.js.map +1 -1
  24. package/dist/features/accordion/adapters/inbound/AccordionTrigger.js +1 -1
  25. package/dist/features/accordion/adapters/inbound/AccordionTrigger.js.map +1 -1
  26. package/dist/features/bottom-sheet/BottomSheetProvider.js +2 -2
  27. package/dist/features/bottom-sheet/BottomSheetProvider.js.map +1 -1
  28. package/dist/features/bottom-sheet/useBottomSheet.js +1 -1
  29. package/dist/features/bottom-sheet/useBottomSheet.js.map +1 -1
  30. package/dist/features/popover/adapters/inbound/Popover.d.ts +5 -0
  31. package/dist/features/popover/adapters/inbound/Popover.d.ts.map +1 -0
  32. package/dist/features/popover/adapters/inbound/Popover.js +31 -0
  33. package/dist/features/popover/adapters/inbound/Popover.js.map +1 -0
  34. package/dist/features/popover/adapters/inbound/PopoverOverlay.d.ts +17 -0
  35. package/dist/features/popover/adapters/inbound/PopoverOverlay.d.ts.map +1 -0
  36. package/dist/features/popover/adapters/inbound/PopoverOverlay.js +34 -0
  37. package/dist/features/popover/adapters/inbound/PopoverOverlay.js.map +1 -0
  38. package/dist/features/popover/adapters/inbound/usePopoverLayout.d.ts +21 -0
  39. package/dist/features/popover/adapters/inbound/usePopoverLayout.d.ts.map +1 -0
  40. package/dist/features/popover/adapters/inbound/usePopoverLayout.js +33 -0
  41. package/dist/features/popover/adapters/inbound/usePopoverLayout.js.map +1 -0
  42. package/dist/features/popover/public.d.ts +3 -0
  43. package/dist/features/popover/public.d.ts.map +1 -0
  44. package/dist/features/popover/public.js +2 -0
  45. package/dist/features/popover/public.js.map +1 -0
  46. package/dist/features/popover/utils/resolvePopoverPosition.d.ts +25 -0
  47. package/dist/features/popover/utils/resolvePopoverPosition.d.ts.map +1 -0
  48. package/dist/features/popover/utils/resolvePopoverPosition.js +23 -0
  49. package/dist/features/popover/utils/resolvePopoverPosition.js.map +1 -0
  50. package/dist/{components/toast → features/toast/adapters/inbound}/Toast.d.ts +2 -1
  51. package/dist/features/toast/adapters/inbound/Toast.d.ts.map +1 -0
  52. package/dist/{components/toast → features/toast/adapters/inbound}/Toast.js +37 -32
  53. package/dist/features/toast/adapters/inbound/Toast.js.map +1 -0
  54. package/dist/features/toast/adapters/inbound/ToastProvider.d.ts +5 -0
  55. package/dist/features/toast/adapters/inbound/ToastProvider.d.ts.map +1 -0
  56. package/dist/features/toast/adapters/inbound/ToastProvider.js +29 -0
  57. package/dist/features/toast/adapters/inbound/ToastProvider.js.map +1 -0
  58. package/dist/features/toast/adapters/inbound/useToast.d.ts +4 -0
  59. package/dist/features/toast/adapters/inbound/useToast.d.ts.map +1 -0
  60. package/dist/features/toast/adapters/inbound/useToast.js +11 -0
  61. package/dist/features/toast/adapters/inbound/useToast.js.map +1 -0
  62. package/dist/features/toast/composition/ToastContext.d.ts +4 -0
  63. package/dist/features/toast/composition/ToastContext.d.ts.map +1 -0
  64. package/dist/features/toast/composition/ToastContext.js +4 -0
  65. package/dist/features/toast/composition/ToastContext.js.map +1 -0
  66. package/dist/features/toast/composition/useToastRuntime.d.ts +11 -0
  67. package/dist/features/toast/composition/useToastRuntime.d.ts.map +1 -0
  68. package/dist/features/toast/composition/useToastRuntime.js +50 -0
  69. package/dist/features/toast/composition/useToastRuntime.js.map +1 -0
  70. package/dist/features/toast/public.d.ts +5 -0
  71. package/dist/features/toast/public.d.ts.map +1 -0
  72. package/dist/features/toast/public.js +4 -0
  73. package/dist/features/toast/public.js.map +1 -0
  74. package/dist/index.d.ts +4 -2
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal/overlay/OverlayProvider.d.ts +1 -0
  79. package/dist/internal/overlay/OverlayProvider.d.ts.map +1 -1
  80. package/dist/internal/overlay/OverlayProvider.js +9 -7
  81. package/dist/internal/overlay/OverlayProvider.js.map +1 -1
  82. package/dist/internal/overlay/useOverlayStack.js +1 -1
  83. package/dist/internal/overlay/useOverlayStack.js.map +1 -1
  84. package/dist/internal/resolvers/resolveOverlayZIndex.d.ts +1 -1
  85. package/dist/internal/resolvers/resolveOverlayZIndex.d.ts.map +1 -1
  86. package/dist/internal/resolvers/resolveOverlayZIndex.js +1 -0
  87. package/dist/internal/resolvers/resolveOverlayZIndex.js.map +1 -1
  88. package/dist/layout/AppBar.js +1 -1
  89. package/dist/layout/AppBar.js.map +1 -1
  90. package/dist/theme/ThemeContext.d.ts.map +1 -1
  91. package/dist/theme/ThemeContext.js +4 -5
  92. package/dist/theme/ThemeContext.js.map +1 -1
  93. package/dist/types/popover.d.ts +21 -0
  94. package/dist/types/popover.d.ts.map +1 -0
  95. package/dist/types/popover.js +2 -0
  96. package/dist/types/popover.js.map +1 -0
  97. package/dist/{components/toast/types.d.ts → types/toast.d.ts} +11 -3
  98. package/dist/types/toast.d.ts.map +1 -0
  99. package/dist/types/toast.js +2 -0
  100. package/dist/types/toast.js.map +1 -0
  101. package/package.json +1 -1
  102. package/src/components/tabs/Tabs.tsx +2 -2
  103. package/src/components/tabs/context.tsx +1 -1
  104. package/src/context/FontContext.tsx +3 -3
  105. package/src/context/TranslationContext.tsx +3 -4
  106. package/src/core/responsive/ResponsiveProvider.tsx +3 -3
  107. package/src/examples/DocsExamples.tsx +1 -1
  108. package/src/features/accordion/adapters/inbound/Accordion.tsx +2 -2
  109. package/src/features/accordion/adapters/inbound/AccordionContent.tsx +1 -1
  110. package/src/features/accordion/adapters/inbound/AccordionItem.tsx +3 -3
  111. package/src/features/accordion/adapters/inbound/AccordionTrigger.tsx +1 -1
  112. package/src/features/bottom-sheet/BottomSheetProvider.tsx +2 -2
  113. package/src/features/bottom-sheet/useBottomSheet.ts +1 -1
  114. package/src/features/popover/adapters/inbound/Popover.tsx +49 -0
  115. package/src/features/popover/adapters/inbound/PopoverOverlay.tsx +72 -0
  116. package/src/features/popover/adapters/inbound/usePopoverLayout.ts +51 -0
  117. package/src/features/popover/public.ts +2 -0
  118. package/src/features/popover/utils/resolvePopoverPosition.test.ts +31 -0
  119. package/src/features/popover/utils/resolvePopoverPosition.ts +40 -0
  120. package/src/{components/toast → features/toast/adapters/inbound}/Toast.tsx +58 -54
  121. package/src/features/toast/adapters/inbound/ToastProvider.tsx +47 -0
  122. package/src/features/toast/adapters/inbound/useToast.test.tsx +18 -0
  123. package/src/features/toast/adapters/inbound/useToast.ts +15 -0
  124. package/src/features/toast/composition/ToastContext.ts +6 -0
  125. package/src/features/toast/composition/useToastRuntime.ts +76 -0
  126. package/src/features/toast/public.ts +10 -0
  127. package/src/index.test.ts +3 -1
  128. package/src/index.ts +14 -2
  129. package/src/internal/overlay/OverlayProvider.tsx +10 -8
  130. package/src/internal/overlay/useOverlayStack.ts +1 -1
  131. package/src/internal/resolvers/resolveOverlayZIndex.test.ts +2 -1
  132. package/src/internal/resolvers/resolveOverlayZIndex.ts +2 -1
  133. package/src/layout/AppBar.tsx +1 -1
  134. package/src/theme/ThemeContext.tsx +4 -5
  135. package/src/types/popover.ts +25 -0
  136. package/src/{components/toast/types.ts → types/toast.ts} +12 -2
  137. package/dist/components/toast/Toast.d.ts.map +0 -1
  138. package/dist/components/toast/Toast.js.map +0 -1
  139. package/dist/components/toast/ToastProvider.d.ts +0 -13
  140. package/dist/components/toast/ToastProvider.d.ts.map +0 -1
  141. package/dist/components/toast/ToastProvider.js +0 -70
  142. package/dist/components/toast/ToastProvider.js.map +0 -1
  143. package/dist/components/toast/index.d.ts +0 -4
  144. package/dist/components/toast/index.d.ts.map +0 -1
  145. package/dist/components/toast/index.js +0 -3
  146. package/dist/components/toast/index.js.map +0 -1
  147. package/dist/components/toast/types.d.ts.map +0 -1
  148. package/dist/components/toast/types.js +0 -2
  149. package/dist/components/toast/types.js.map +0 -1
  150. package/src/components/toast/ToastProvider.tsx +0 -115
  151. package/src/components/toast/index.ts +0 -3
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native';
3
+
4
+ import { Portal } from '../../../../internal/overlay/Portal';
5
+ import type { PopoverPlacement } from '../../../../types/popover';
6
+ import { usePopoverLayout } from './usePopoverLayout';
7
+
8
+ /*** Renders and positions one mounted popover overlay for an open anchor. */
9
+ export function PopoverOverlay({
10
+ anchorRef,
11
+ children,
12
+ closeOnOutsidePress,
13
+ offset,
14
+ onClose,
15
+ passive,
16
+ placement,
17
+ testID,
18
+ }: PopoverOverlayProps) {
19
+ const { onContentLayout, position, ready } = usePopoverLayout({
20
+ anchorRef,
21
+ offset,
22
+ open: true,
23
+ placement,
24
+ });
25
+
26
+ return (
27
+ <Portal layer="popover" visible={ready}>
28
+ <View pointerEvents="box-none" style={styles.overlay}>
29
+ {closeOnOutsidePress ? (
30
+ <Pressable
31
+ accessibilityLabel="Close popover"
32
+ onPress={passive ? undefined : onClose}
33
+ style={styles.backdrop}
34
+ testID={testID ? `${testID}-backdrop` : undefined}
35
+ />
36
+ ) : null}
37
+ <View
38
+ onLayout={onContentLayout}
39
+ style={[styles.content, position]}
40
+ testID={testID ? `${testID}-content` : undefined}
41
+ >
42
+ {children}
43
+ </View>
44
+ </View>
45
+ </Portal>
46
+ );
47
+ }
48
+
49
+ interface PopoverOverlayProps {
50
+ anchorRef: React.RefObject<View | null>;
51
+ children?: React.ReactNode;
52
+ closeOnOutsidePress: boolean;
53
+ offset: number;
54
+ onClose: () => void;
55
+ passive: boolean;
56
+ placement: PopoverPlacement;
57
+ testID?: string;
58
+ }
59
+
60
+ const absoluteFill: ViewStyle = {
61
+ bottom: 0,
62
+ left: 0,
63
+ position: 'absolute',
64
+ right: 0,
65
+ top: 0,
66
+ };
67
+
68
+ const styles = StyleSheet.create({
69
+ backdrop: absoluteFill,
70
+ content: { position: 'absolute' },
71
+ overlay: absoluteFill,
72
+ });
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import {
3
+ type LayoutChangeEvent,
4
+ type LayoutRectangle,
5
+ useWindowDimensions,
6
+ type View,
7
+ } from 'react-native';
8
+
9
+ import type { PopoverPlacement } from '../../../../types/popover';
10
+ import { resolvePopoverPosition } from '../../utils/resolvePopoverPosition';
11
+
12
+ /*** Measures the anchor and resolves the current viewport-safe popover position. */
13
+ export function usePopoverLayout({ anchorRef, offset, open, placement }: UsePopoverLayoutInput) {
14
+ const [anchorLayout, setAnchorLayout] = React.useState<LayoutRectangle | null>(null);
15
+ const [contentSize, setContentSize] = React.useState({ height: 0, width: 0 });
16
+ const { height: viewportHeight, width: viewportWidth } = useWindowDimensions();
17
+
18
+ const measureAnchor = React.useCallback(() => {
19
+ anchorRef.current?.measureInWindow((x, y, width, height) => {
20
+ setAnchorLayout({ height, width, x, y });
21
+ });
22
+ }, [anchorRef]);
23
+
24
+ React.useEffect(() => {
25
+ if (open) measureAnchor();
26
+ }, [measureAnchor, open]);
27
+
28
+ const onContentLayout = React.useCallback((event: LayoutChangeEvent) => {
29
+ setContentSize(event.nativeEvent.layout);
30
+ }, []);
31
+
32
+ const position = anchorLayout
33
+ ? resolvePopoverPosition({
34
+ anchor: anchorLayout,
35
+ contentSize,
36
+ offset,
37
+ placement,
38
+ viewport: { height: viewportHeight, width: viewportWidth },
39
+ viewportPadding: 8,
40
+ })
41
+ : undefined;
42
+
43
+ return { measureAnchor, onContentLayout, position, ready: Boolean(anchorLayout) };
44
+ }
45
+
46
+ interface UsePopoverLayoutInput {
47
+ anchorRef: React.RefObject<View | null>;
48
+ offset: number;
49
+ open: boolean;
50
+ placement: PopoverPlacement;
51
+ }
@@ -0,0 +1,2 @@
1
+ export type { PopoverAnchorRenderProps, PopoverPlacement, PopoverProps } from '../../types/popover';
2
+ export { Popover } from './adapters/inbound/Popover';
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { resolvePopoverPosition } from './resolvePopoverPosition';
4
+
5
+ describe('resolvePopoverPosition', () => {
6
+ it('places bottom-start content below the anchor', () => {
7
+ expect(
8
+ resolvePopoverPosition({
9
+ anchor: { height: 40, width: 120, x: 100, y: 80 },
10
+ contentSize: { height: 180, width: 240 },
11
+ offset: 8,
12
+ placement: 'bottom-start',
13
+ viewport: { height: 800, width: 1200 },
14
+ viewportPadding: 8,
15
+ }),
16
+ ).toEqual({ left: 100, top: 128 });
17
+ });
18
+
19
+ it('clamps content to the available viewport', () => {
20
+ expect(
21
+ resolvePopoverPosition({
22
+ anchor: { height: 40, width: 80, x: 980, y: 700 },
23
+ contentSize: { height: 240, width: 300 },
24
+ offset: 8,
25
+ placement: 'bottom-end',
26
+ viewport: { height: 800, width: 1200 },
27
+ viewportPadding: 8,
28
+ }),
29
+ ).toEqual({ left: 760, top: 552 });
30
+ });
31
+ });
@@ -0,0 +1,40 @@
1
+ import type { PopoverPlacement } from '../../../types/popover';
2
+
3
+ /*** Resolves a viewport-clamped anchored position for popover content. */
4
+ export function resolvePopoverPosition({
5
+ anchor,
6
+ contentSize,
7
+ offset,
8
+ placement,
9
+ viewport,
10
+ viewportPadding,
11
+ }: {
12
+ anchor: { height: number; width: number; x: number; y: number };
13
+ contentSize: { height: number; width: number };
14
+ offset: number;
15
+ placement: PopoverPlacement;
16
+ viewport: { height: number; width: number };
17
+ viewportPadding: number;
18
+ }): { left: number; top: number } {
19
+ const anchorCenterX = anchor.x + anchor.width / 2;
20
+ const rawLeft = placement.endsWith('-start')
21
+ ? anchor.x
22
+ : placement.endsWith('-end')
23
+ ? anchor.x + anchor.width - contentSize.width
24
+ : anchorCenterX - contentSize.width / 2;
25
+ const rawTop = placement.startsWith('top')
26
+ ? anchor.y - contentSize.height - offset
27
+ : anchor.y + anchor.height + offset;
28
+ const maxLeft = Math.max(viewportPadding, viewport.width - contentSize.width - viewportPadding);
29
+ const maxTop = Math.max(viewportPadding, viewport.height - contentSize.height - viewportPadding);
30
+
31
+ return {
32
+ left: clamp(rawLeft, viewportPadding, maxLeft),
33
+ top: clamp(rawTop, viewportPadding, maxTop),
34
+ };
35
+ }
36
+
37
+ /*** Clamps one numeric position into the available viewport range. */
38
+ function clamp(value: number, minimum: number, maximum: number): number {
39
+ return Math.min(Math.max(value, minimum), maximum);
40
+ }
@@ -1,45 +1,15 @@
1
1
  import React from 'react';
2
- import { Pressable } from 'react-native';
2
+ import { Pressable, StyleSheet, type ViewStyle } from 'react-native';
3
3
 
4
- import { Box } from '../../features/layout/public';
5
- import { Surface } from '../../features/surface/public';
6
- import { Text } from '../../features/typography/public';
7
- import { Inline } from '../../layout';
8
- import type { SurfaceColor } from '../../surfaceColor';
9
- import { useTheme } from '../../theme/ThemeContext';
10
- import type { SurfaceTheme } from '../../theme/types';
11
- import type { ToastProps } from './types';
12
-
13
- function resolveToastStatusColor(theme: SurfaceTheme, status: ToastProps['status']) {
14
- switch (status) {
15
- case 'success':
16
- return theme.semantics.success.base;
17
- case 'warning':
18
- return theme.semantics.warning.base;
19
- case 'error':
20
- return theme.semantics.error.base;
21
- case 'info':
22
- return theme.semantics.info.base;
23
- default:
24
- return theme.semantics.action.primary.base;
25
- }
26
- }
27
-
28
- function resolveToastStatusTextColor(status: ToastProps['status']): SurfaceColor {
29
- switch (status) {
30
- case 'success':
31
- return 'success';
32
- case 'warning':
33
- return 'warning';
34
- case 'error':
35
- return 'error';
36
- case 'info':
37
- return 'info';
38
- default:
39
- return 'primary';
40
- }
41
- }
4
+ import type { SurfaceColor } from '../../../../surfaceColor';
5
+ import { useTheme } from '../../../../theme/ThemeContext';
6
+ import type { SurfaceTheme } from '../../../../theme/types';
7
+ import type { ToastProps } from '../../../../types/toast';
8
+ import { Box, Stack } from '../../../layout/public';
9
+ import { Surface } from '../../../surface/public';
10
+ import { Text } from '../../../typography/public';
42
11
 
12
+ /*** Renders one transient toast notification. */
43
13
  export function Toast({
44
14
  title,
45
15
  description,
@@ -52,22 +22,14 @@ export function Toast({
52
22
  const statusColor = resolveToastStatusColor(theme, status);
53
23
  const statusTextColor = resolveToastStatusTextColor(status);
54
24
  const passive = interactionPolicy === 'passive';
25
+ const statusStyle = React.useMemo<ViewStyle>(
26
+ () => ({ borderLeftColor: statusColor }),
27
+ [statusColor],
28
+ );
55
29
 
56
30
  return (
57
- <Surface
58
- p="m"
59
- style={{
60
- borderLeftColor: statusColor,
61
- borderLeftWidth: 3,
62
- minWidth: 280,
63
- shadowOpacity: 0.14,
64
- shadowRadius: 12,
65
- shadowOffset: { width: 0, height: 6 },
66
- }}
67
- testID={testID}
68
- variant="raised"
69
- >
70
- <Inline align="center" justify="space-between">
31
+ <Surface p="m" style={[styles.root, statusStyle]} testID={testID} variant="raised">
32
+ <Stack align="center" direction="row" justify="space-between">
71
33
  <Box flex={1}>
72
34
  {title ? (
73
35
  <Text variant="label" weight="medium">
@@ -86,7 +48,49 @@ export function Toast({
86
48
  <Text color={statusTextColor}>×</Text>
87
49
  </Pressable>
88
50
  ) : null}
89
- </Inline>
51
+ </Stack>
90
52
  </Surface>
91
53
  );
92
54
  }
55
+
56
+ /*** Resolves the border color representing a toast status. */
57
+ function resolveToastStatusColor(theme: SurfaceTheme, status: ToastProps['status']) {
58
+ switch (status) {
59
+ case 'success':
60
+ return theme.semantics.success.base;
61
+ case 'warning':
62
+ return theme.semantics.warning.base;
63
+ case 'error':
64
+ return theme.semantics.error.base;
65
+ case 'info':
66
+ return theme.semantics.info.base;
67
+ default:
68
+ return theme.semantics.action.primary.base;
69
+ }
70
+ }
71
+
72
+ /*** Resolves the semantic text color representing a toast status. */
73
+ function resolveToastStatusTextColor(status: ToastProps['status']): SurfaceColor {
74
+ switch (status) {
75
+ case 'success':
76
+ return 'success';
77
+ case 'warning':
78
+ return 'warning';
79
+ case 'error':
80
+ return 'error';
81
+ case 'info':
82
+ return 'info';
83
+ default:
84
+ return 'primary';
85
+ }
86
+ }
87
+
88
+ const styles = StyleSheet.create({
89
+ root: {
90
+ borderLeftWidth: 3,
91
+ minWidth: 280,
92
+ shadowOffset: { height: 6, width: 0 },
93
+ shadowOpacity: 0.14,
94
+ shadowRadius: 12,
95
+ },
96
+ });
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { StyleSheet, type ViewStyle } from 'react-native';
3
+
4
+ import { Portal } from '../../../../internal/overlay/Portal';
5
+ import { resolveOverlayAnimation } from '../../../../internal/resolvers';
6
+ import type { ToastProviderProps } from '../../../../types/toast';
7
+ import { Stack } from '../../../layout/public';
8
+ import { ToastContext } from '../../composition/ToastContext';
9
+ import { useToastRuntime } from '../../composition/useToastRuntime';
10
+ import { Toast } from './Toast';
11
+
12
+ /*** Provides the toast runtime context and renders its shared portal host. */
13
+ export function ToastProvider({ children, defaultDuration = 4000 }: ToastProviderProps) {
14
+ const animation = resolveOverlayAnimation('toast');
15
+ const { controller, toasts } = useToastRuntime(defaultDuration);
16
+ const animationStyle = React.useMemo<ViewStyle>(
17
+ () => ({ paddingTop: 16 + animation.offset }),
18
+ [animation.offset],
19
+ );
20
+
21
+ return (
22
+ <ToastContext value={controller}>
23
+ {children}
24
+ <Portal layer="toast" visible={toasts.length > 0}>
25
+ <Stack gap="s" pointerEvents="box-none" style={[styles.container, animationStyle]}>
26
+ {toasts.map((toast) => (
27
+ <Toast
28
+ description={toast.description}
29
+ key={toast.id}
30
+ onDismiss={() => controller.dismissToast(toast.id)}
31
+ status={toast.status}
32
+ testID={toast.testID}
33
+ title={toast.title}
34
+ />
35
+ ))}
36
+ </Stack>
37
+ </Portal>
38
+ </ToastContext>
39
+ );
40
+ }
41
+
42
+ const styles = StyleSheet.create({
43
+ container: {
44
+ alignItems: 'flex-end',
45
+ padding: 16,
46
+ },
47
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import React from 'react';
3
+ import { renderToStaticMarkup } from 'react-dom/server';
4
+
5
+ import { useToast } from './useToast';
6
+
7
+ describe('useToast', () => {
8
+ it('requires ToastProvider', () => {
9
+ function Consumer() {
10
+ useToast();
11
+ return null;
12
+ }
13
+
14
+ expect(() => renderToStaticMarkup(<Consumer />)).toThrow(
15
+ 'useToast must be used within <ToastProvider>.',
16
+ );
17
+ });
18
+ });
@@ -0,0 +1,15 @@
1
+ import { use } from 'react';
2
+
3
+ import type { ToastController } from '../../../../types/toast';
4
+ import { ToastContext } from '../../composition/ToastContext';
5
+
6
+ /*** Returns the toast controller installed by ToastProvider. */
7
+ export function useToast(): ToastController {
8
+ const controller = use(ToastContext);
9
+
10
+ if (!controller) {
11
+ throw new Error('useToast must be used within <ToastProvider>.');
12
+ }
13
+
14
+ return controller;
15
+ }
@@ -0,0 +1,6 @@
1
+ import { createContext } from 'react';
2
+
3
+ import type { ToastController } from '../../../types/toast';
4
+
5
+ /*** Stores the toast controller shared by ToastProvider and useToast. */
6
+ export const ToastContext = createContext<ToastController | null>(null);
@@ -0,0 +1,76 @@
1
+ import React from 'react';
2
+
3
+ import type { ToastController, ToastOptions } from '../../../types/toast';
4
+
5
+ /*** Owns the toast queue, dismissal timers, and imperative controller state. */
6
+ export function useToastRuntime(defaultDuration: number) {
7
+ const [toasts, setToasts] = React.useState<ToastEntry[]>([]);
8
+ const timersRef = React.useRef(new Map<string, ReturnType<typeof setTimeout>>());
9
+ const counterRef = React.useRef(0);
10
+
11
+ const dismissToast = React.useCallback((id: string) => {
12
+ const timer = timersRef.current.get(id);
13
+ if (timer) {
14
+ clearTimeout(timer);
15
+ timersRef.current.delete(id);
16
+ }
17
+ setToasts((current) => current.filter((toast) => toast.id !== id));
18
+ }, []);
19
+
20
+ const showToast = React.useCallback((options: ToastOptions) => {
21
+ const id = options.id ?? `toast-${counterRef.current++}`;
22
+ setToasts((current) => [...current, { ...options, id }]);
23
+ return id;
24
+ }, []);
25
+
26
+ useToastTimers({ defaultDuration, dismissToast, timersRef, toasts });
27
+
28
+ const controller = React.useMemo<ToastController>(
29
+ () => ({ dismissToast, showToast }),
30
+ [dismissToast, showToast],
31
+ );
32
+
33
+ return { controller, toasts };
34
+ }
35
+
36
+ interface ToastEntry extends ToastOptions {
37
+ id: string;
38
+ }
39
+
40
+ interface UseToastTimersInput {
41
+ defaultDuration: number;
42
+ dismissToast: (id: string) => void;
43
+ timersRef: React.RefObject<Map<string, ReturnType<typeof setTimeout>>>;
44
+ toasts: readonly ToastEntry[];
45
+ }
46
+
47
+ /*** Synchronizes toast lifetimes with timer side effects and cleans them up on unmount. */
48
+ function useToastTimers({ defaultDuration, dismissToast, timersRef, toasts }: UseToastTimersInput) {
49
+ React.useEffect(() => {
50
+ const timers = timersRef.current;
51
+
52
+ toasts.forEach((toast) => {
53
+ if (timers.has(toast.id)) return;
54
+
55
+ const timer = setTimeout(() => dismissToast(toast.id), toast.duration ?? defaultDuration);
56
+ timers.set(toast.id, timer);
57
+ });
58
+
59
+ const activeToastIds = new Set(toasts.map((toast) => toast.id));
60
+ timers.forEach((timer, id) => {
61
+ if (!activeToastIds.has(id)) {
62
+ clearTimeout(timer);
63
+ timers.delete(id);
64
+ }
65
+ });
66
+ }, [defaultDuration, dismissToast, timersRef, toasts]);
67
+
68
+ React.useEffect(() => {
69
+ const timers = timersRef.current;
70
+
71
+ return () => {
72
+ timers.forEach((timer) => clearTimeout(timer));
73
+ timers.clear();
74
+ };
75
+ }, [timersRef]);
76
+ }
@@ -0,0 +1,10 @@
1
+ export type {
2
+ ToastController,
3
+ ToastOptions,
4
+ ToastProps,
5
+ ToastProviderProps,
6
+ ToastStatus,
7
+ } from '../../types/toast';
8
+ export { Toast } from './adapters/inbound/Toast';
9
+ export { ToastProvider } from './adapters/inbound/ToastProvider';
10
+ export { useToast } from './adapters/inbound/useToast';
package/src/index.test.ts CHANGED
@@ -28,7 +28,9 @@ const expectedFeatureRootExports = [
28
28
  "export { Image } from './features/image/public';",
29
29
  "export { KeyboardAvoidingView } from './features/keyboard-avoiding-view/public';",
30
30
  "export { Box, Container, Divider, Grid, Stack } from './features/layout/public';",
31
+ "export { Popover } from './features/popover/public';",
31
32
  "export { Surface } from './features/surface/public';",
33
+ "export { Toast, ToastProvider, useToast } from './features/toast/public';",
32
34
  "export { Heading, Text } from './features/typography/public';",
33
35
  ] as const;
34
36
 
@@ -42,7 +44,6 @@ const expectedLegacyRootExports = [
42
44
  "export { Switch } from './components/switch';",
43
45
  "export { Tab, TabList, TabPanel, Tabs } from './components/tabs';",
44
46
  "export { Textarea } from './components/textarea';",
45
- "export { Toast, ToastProvider, useToast } from './components/toast';",
46
47
  "export { Tooltip } from './components/tooltip';",
47
48
  "export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';",
48
49
  "export * from './core/responsive';",
@@ -63,6 +64,7 @@ describe('feature-owned root barrel contract', () => {
63
64
  './components/icon-button',
64
65
  './components/radio',
65
66
  './components/text-input',
67
+ './components/toast',
66
68
  './primitives/heading',
67
69
  './primitives/icon',
68
70
  './primitives/image',
package/src/index.ts CHANGED
@@ -16,8 +16,6 @@ export type { TabListProps, TabPanelProps, TabProps, TabsProps } from './compone
16
16
  export { Tab, TabList, TabPanel, Tabs } from './components/tabs';
17
17
  export type { TextareaProps } from './components/textarea';
18
18
  export { Textarea } from './components/textarea';
19
- export type { ToastOptions, ToastProps, ToastStatus } from './components/toast';
20
- export { Toast, ToastProvider, useToast } from './components/toast';
21
19
  export type { TooltipProps } from './components/tooltip';
22
20
  export { Tooltip } from './components/tooltip';
23
21
  export type { FontRuntime } from './context/FontContext';
@@ -76,8 +74,22 @@ export type {
76
74
  StackProps,
77
75
  } from './features/layout/public';
78
76
  export { Box, Container, Divider, Grid, Stack } from './features/layout/public';
77
+ export type {
78
+ PopoverAnchorRenderProps,
79
+ PopoverPlacement,
80
+ PopoverProps,
81
+ } from './features/popover/public';
82
+ export { Popover } from './features/popover/public';
79
83
  export type { SurfaceProps, SurfaceVariant } from './features/surface/public';
80
84
  export { Surface } from './features/surface/public';
85
+ export type {
86
+ ToastController,
87
+ ToastOptions,
88
+ ToastProps,
89
+ ToastProviderProps,
90
+ ToastStatus,
91
+ } from './features/toast/public';
92
+ export { Toast, ToastProvider, useToast } from './features/toast/public';
81
93
  export type { HeadingLevel, HeadingProps, TextProps } from './features/typography/public';
82
94
  export { Heading, Text } from './features/typography/public';
83
95
  export type { InteractionPolicy, InteractionPolicyProps } from './interactionPolicy';
@@ -22,10 +22,7 @@ const styles = StyleSheet.create({
22
22
  },
23
23
  });
24
24
 
25
- function overlayZIndex(zIndex: number): ViewStyle {
26
- return { zIndex };
27
- }
28
-
25
+ /*** Installs the shared overlay stack used by portal-based Surface features. */
29
26
  export function OverlayProvider({ children }: { children: React.ReactNode }) {
30
27
  const orderRef = React.useRef(0);
31
28
  const [overlays, setOverlays] = React.useState<OverlayEntry[]>([]);
@@ -65,8 +62,8 @@ export function OverlayProvider({ children }: { children: React.ReactNode }) {
65
62
  );
66
63
 
67
64
  return (
68
- <OverlayStackActionsContext.Provider value={actions}>
69
- <OverlayStackContext.Provider value={value}>
65
+ <OverlayStackActionsContext value={actions}>
66
+ <OverlayStackContext value={value}>
70
67
  {children}
71
68
  <View {...boxNonePointerEvents.props} style={[boxNonePointerEvents.style, styles.fill]}>
72
69
  {overlays.map((overlay) => (
@@ -79,7 +76,12 @@ export function OverlayProvider({ children }: { children: React.ReactNode }) {
79
76
  </View>
80
77
  ))}
81
78
  </View>
82
- </OverlayStackContext.Provider>
83
- </OverlayStackActionsContext.Provider>
79
+ </OverlayStackContext>
80
+ </OverlayStackActionsContext>
84
81
  );
85
82
  }
83
+
84
+ /*** Resolves a per-entry z-index style for the overlay host. */
85
+ function overlayZIndex(zIndex: number): ViewStyle {
86
+ return { zIndex };
87
+ }
@@ -48,7 +48,7 @@ export const OverlayStackContext = React.createContext<OverlayStackRuntime | nul
48
48
  export const OverlayStackActionsContext = React.createContext<OverlayStackActions | null>(null);
49
49
 
50
50
  export function useOverlayStackActions(): OverlayStackActions | null {
51
- return React.useContext(OverlayStackActionsContext);
51
+ return React.use(OverlayStackActionsContext);
52
52
  }
53
53
 
54
54
  export function createOverlayEntry(
@@ -5,7 +5,8 @@ import { resolveOverlayZIndex } from './resolveOverlayZIndex';
5
5
  describe('resolveOverlayZIndex', () => {
6
6
  it('assigns higher base layers to later overlay types', () => {
7
7
  expect(resolveOverlayZIndex('modal')).toBeLessThan(resolveOverlayZIndex('menu'));
8
- expect(resolveOverlayZIndex('menu')).toBeLessThan(resolveOverlayZIndex('tooltip'));
8
+ expect(resolveOverlayZIndex('menu')).toBeLessThan(resolveOverlayZIndex('popover'));
9
+ expect(resolveOverlayZIndex('popover')).toBeLessThan(resolveOverlayZIndex('tooltip'));
9
10
  expect(resolveOverlayZIndex('tooltip')).toBeLessThan(resolveOverlayZIndex('toast'));
10
11
  });
11
12
 
@@ -1,9 +1,10 @@
1
- export type OverlayLayer = 'modal' | 'drawer' | 'menu' | 'tooltip' | 'toast';
1
+ export type OverlayLayer = 'modal' | 'drawer' | 'menu' | 'popover' | 'tooltip' | 'toast';
2
2
 
3
3
  const BASE_Z_INDEX: Record<OverlayLayer, number> = {
4
4
  modal: 1000,
5
5
  drawer: 1100,
6
6
  menu: 1200,
7
+ popover: 1250,
7
8
  tooltip: 1300,
8
9
  toast: 1400,
9
10
  };
@@ -26,7 +26,7 @@ export function AppBar({
26
26
  ...props
27
27
  }: AppBarProps) {
28
28
  const { theme } = useTheme();
29
- const insets = React.useContext(SafeAreaInsetsContext);
29
+ const insets = React.use(SafeAreaInsetsContext);
30
30
  const topInset = safeAreaTop ? (insets?.top ?? 0) : 0;
31
31
  const safeAreaStyle: ViewStyle | undefined =
32
32
  topInset === 0 ? undefined : { paddingTop: topInset };