@oxyhq/bloom 0.6.8 → 0.6.10

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 (117) hide show
  1. package/lib/commonjs/accordion/Accordion.js +12 -3
  2. package/lib/commonjs/accordion/Accordion.js.map +1 -1
  3. package/lib/commonjs/button/Button.js +31 -7
  4. package/lib/commonjs/button/Button.js.map +1 -1
  5. package/lib/commonjs/card/Card.js +12 -3
  6. package/lib/commonjs/card/Card.js.map +1 -1
  7. package/lib/commonjs/dialog/CenteredDialog.js +242 -0
  8. package/lib/commonjs/dialog/CenteredDialog.js.map +1 -0
  9. package/lib/commonjs/dialog/CenteredDialog.web.js +326 -0
  10. package/lib/commonjs/dialog/CenteredDialog.web.js.map +1 -0
  11. package/lib/commonjs/dialog/centered-dialog-tokens.js +62 -0
  12. package/lib/commonjs/dialog/centered-dialog-tokens.js.map +1 -0
  13. package/lib/commonjs/dialog/centered-dialog-types.js +6 -0
  14. package/lib/commonjs/dialog/centered-dialog-types.js.map +1 -0
  15. package/lib/commonjs/dialog/index.js +13 -0
  16. package/lib/commonjs/dialog/index.js.map +1 -1
  17. package/lib/commonjs/dialog/index.web.js +19 -0
  18. package/lib/commonjs/dialog/index.web.js.map +1 -1
  19. package/lib/commonjs/index.js +14 -0
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/index.web.js +14 -0
  22. package/lib/commonjs/index.web.js.map +1 -1
  23. package/lib/commonjs/segmented-control/index.js +12 -3
  24. package/lib/commonjs/segmented-control/index.js.map +1 -1
  25. package/lib/commonjs/settings-list/SettingsList.js +11 -3
  26. package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
  27. package/lib/commonjs/toast/Toast.js +13 -6
  28. package/lib/commonjs/toast/Toast.js.map +1 -1
  29. package/lib/module/accordion/Accordion.js +12 -3
  30. package/lib/module/accordion/Accordion.js.map +1 -1
  31. package/lib/module/button/Button.js +31 -7
  32. package/lib/module/button/Button.js.map +1 -1
  33. package/lib/module/card/Card.js +12 -3
  34. package/lib/module/card/Card.js.map +1 -1
  35. package/lib/module/dialog/CenteredDialog.js +236 -0
  36. package/lib/module/dialog/CenteredDialog.js.map +1 -0
  37. package/lib/module/dialog/CenteredDialog.web.js +320 -0
  38. package/lib/module/dialog/CenteredDialog.web.js.map +1 -0
  39. package/lib/module/dialog/centered-dialog-tokens.js +58 -0
  40. package/lib/module/dialog/centered-dialog-tokens.js.map +1 -0
  41. package/lib/module/dialog/centered-dialog-types.js +4 -0
  42. package/lib/module/dialog/centered-dialog-types.js.map +1 -0
  43. package/lib/module/dialog/index.js +1 -0
  44. package/lib/module/dialog/index.js.map +1 -1
  45. package/lib/module/dialog/index.web.js +1 -0
  46. package/lib/module/dialog/index.web.js.map +1 -1
  47. package/lib/module/index.js +1 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/index.web.js +1 -1
  50. package/lib/module/index.web.js.map +1 -1
  51. package/lib/module/segmented-control/index.js +12 -3
  52. package/lib/module/segmented-control/index.js.map +1 -1
  53. package/lib/module/settings-list/SettingsList.js +11 -3
  54. package/lib/module/settings-list/SettingsList.js.map +1 -1
  55. package/lib/module/toast/Toast.js +13 -6
  56. package/lib/module/toast/Toast.js.map +1 -1
  57. package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/card/Card.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts +20 -0
  61. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts +29 -0
  63. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts.map +1 -0
  64. package/lib/typescript/commonjs/dialog/centered-dialog-tokens.d.ts +47 -0
  65. package/lib/typescript/commonjs/dialog/centered-dialog-tokens.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts +70 -0
  67. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/dialog/index.d.ts +2 -0
  69. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -0
  71. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/index.d.ts +2 -2
  73. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/index.web.d.ts +2 -2
  75. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/segmented-control/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/settings-list/SettingsList.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/toast/Toast.d.ts.map +1 -1
  79. package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
  80. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  81. package/lib/typescript/module/card/Card.d.ts.map +1 -1
  82. package/lib/typescript/module/dialog/CenteredDialog.d.ts +20 -0
  83. package/lib/typescript/module/dialog/CenteredDialog.d.ts.map +1 -0
  84. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts +29 -0
  85. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts.map +1 -0
  86. package/lib/typescript/module/dialog/centered-dialog-tokens.d.ts +47 -0
  87. package/lib/typescript/module/dialog/centered-dialog-tokens.d.ts.map +1 -0
  88. package/lib/typescript/module/dialog/centered-dialog-types.d.ts +70 -0
  89. package/lib/typescript/module/dialog/centered-dialog-types.d.ts.map +1 -0
  90. package/lib/typescript/module/dialog/index.d.ts +2 -0
  91. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  92. package/lib/typescript/module/dialog/index.web.d.ts +2 -0
  93. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  94. package/lib/typescript/module/index.d.ts +2 -2
  95. package/lib/typescript/module/index.d.ts.map +1 -1
  96. package/lib/typescript/module/index.web.d.ts +2 -2
  97. package/lib/typescript/module/index.web.d.ts.map +1 -1
  98. package/lib/typescript/module/segmented-control/index.d.ts.map +1 -1
  99. package/lib/typescript/module/settings-list/SettingsList.d.ts.map +1 -1
  100. package/lib/typescript/module/toast/Toast.d.ts.map +1 -1
  101. package/package.json +1 -1
  102. package/src/__tests__/CenteredDialog.test.tsx +108 -0
  103. package/src/accordion/Accordion.tsx +9 -1
  104. package/src/button/Button.tsx +32 -6
  105. package/src/card/Card.tsx +10 -2
  106. package/src/dialog/CenteredDialog.stories.tsx +125 -0
  107. package/src/dialog/CenteredDialog.tsx +295 -0
  108. package/src/dialog/CenteredDialog.web.tsx +370 -0
  109. package/src/dialog/centered-dialog-tokens.ts +68 -0
  110. package/src/dialog/centered-dialog-types.ts +70 -0
  111. package/src/dialog/index.ts +2 -0
  112. package/src/dialog/index.web.ts +6 -0
  113. package/src/index.ts +3 -0
  114. package/src/index.web.ts +3 -0
  115. package/src/segmented-control/index.tsx +9 -1
  116. package/src/settings-list/SettingsList.tsx +8 -1
  117. package/src/toast/Toast.tsx +20 -14
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { Modal, Text } from 'react-native';
3
+ import { fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { CenteredDialog, CENTERED_DIALOG_BACKDROP_TESTID } from '../dialog';
6
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
+
8
+ function renderWithTheme(ui: React.ReactElement) {
9
+ return render(
10
+ <BloomThemeProvider mode="light" colorPreset="teal">
11
+ {ui}
12
+ </BloomThemeProvider>,
13
+ );
14
+ }
15
+
16
+ const CLOSE_LABEL = 'Close dialog';
17
+
18
+ describe('CenteredDialog', () => {
19
+ it('renders the title, children and footer when visible', () => {
20
+ const { getByText } = renderWithTheme(
21
+ <CenteredDialog
22
+ visible
23
+ onClose={() => {}}
24
+ title="Confirm action"
25
+ footer={<Text>Footer area</Text>}
26
+ >
27
+ <Text>Body content</Text>
28
+ </CenteredDialog>,
29
+ );
30
+ expect(getByText('Confirm action')).toBeTruthy();
31
+ expect(getByText('Body content')).toBeTruthy();
32
+ expect(getByText('Footer area')).toBeTruthy();
33
+ });
34
+
35
+ it('forwards visible=false to the underlying Modal', () => {
36
+ const { UNSAFE_getByType } = renderWithTheme(
37
+ <CenteredDialog visible={false} onClose={() => {}} title="Hidden">
38
+ <Text>Body</Text>
39
+ </CenteredDialog>,
40
+ );
41
+ // The native fork always mounts the RN Modal; the platform hides it via
42
+ // the `visible` prop. Assert the prop is wired through.
43
+ expect(UNSAFE_getByType(Modal).props.visible).toBe(false);
44
+ });
45
+
46
+ it('renders a header close button by default when a title is set and fires onClose', () => {
47
+ const onClose = jest.fn();
48
+ const { getByLabelText } = renderWithTheme(
49
+ <CenteredDialog visible onClose={onClose} title="With close">
50
+ <Text>Body</Text>
51
+ </CenteredDialog>,
52
+ );
53
+ fireEvent.press(getByLabelText(CLOSE_LABEL));
54
+ expect(onClose).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it('omits the header close button when showClose is false even with a title', () => {
58
+ const { queryByLabelText } = renderWithTheme(
59
+ <CenteredDialog visible onClose={() => {}} title="No close" showClose={false}>
60
+ <Text>Body</Text>
61
+ </CenteredDialog>,
62
+ );
63
+ expect(queryByLabelText(CLOSE_LABEL)).toBeNull();
64
+ });
65
+
66
+ it('closes when the backdrop is pressed (dismissible default)', () => {
67
+ const onClose = jest.fn();
68
+ const { getByTestId } = renderWithTheme(
69
+ <CenteredDialog visible onClose={onClose}>
70
+ <Text>Body</Text>
71
+ </CenteredDialog>,
72
+ );
73
+ fireEvent.press(getByTestId(CENTERED_DIALOG_BACKDROP_TESTID));
74
+ expect(onClose).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('does NOT close on backdrop press when dismissible is false', () => {
78
+ const onClose = jest.fn();
79
+ const { getByTestId } = renderWithTheme(
80
+ <CenteredDialog visible onClose={onClose} dismissible={false}>
81
+ <Text>Body</Text>
82
+ </CenteredDialog>,
83
+ );
84
+ fireEvent.press(getByTestId(CENTERED_DIALOG_BACKDROP_TESTID));
85
+ expect(onClose).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('derives the backdrop testID from a provided testID', () => {
89
+ const { getByTestId } = renderWithTheme(
90
+ <CenteredDialog visible onClose={() => {}} testID="signout">
91
+ <Text>Body</Text>
92
+ </CenteredDialog>,
93
+ );
94
+ expect(getByTestId('signout-backdrop')).toBeTruthy();
95
+ });
96
+
97
+ it('renders a chromeless card (no header) when no title and showClose unset', () => {
98
+ const { queryByLabelText, getByText, getByTestId } = renderWithTheme(
99
+ <CenteredDialog visible onClose={() => {}}>
100
+ <Text>Chromeless body</Text>
101
+ </CenteredDialog>,
102
+ );
103
+ expect(getByText('Chromeless body')).toBeTruthy();
104
+ // No header close button; only the backdrop is interactive.
105
+ expect(queryByLabelText(CLOSE_LABEL)).toBeNull();
106
+ expect(getByTestId(CENTERED_DIALOG_BACKDROP_TESTID)).toBeTruthy();
107
+ });
108
+ });
@@ -2,6 +2,7 @@ import React, { createContext, memo, useCallback, useContext, useEffect, useMemo
2
2
  import { View, Text, Pressable, Animated, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { useTheme } from '../theme/use-theme';
5
+ import { useInteractionState } from '../hooks/useInteractionState';
5
6
  import { animation, borderRadius, space } from '../styles/tokens';
6
7
  import { SUPPORTS_NATIVE_DRIVER } from '../styles/native-driver';
7
8
  import type {
@@ -132,6 +133,11 @@ const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
132
133
  const { toggle } = useContext(AccordionContext);
133
134
  const { value, isExpanded, disabled } = useContext(AccordionItemContext);
134
135
  const rotateAnim = useRef(new Animated.Value(isExpanded ? 1 : 0)).current;
136
+ // Drive press-opacity via state, not Pressable's function-form `style`,
137
+ // which NativeWind v4's css-interop swallows (dropping the trigger's base
138
+ // layout: flexDirection, padding, gap).
139
+ const { state: pressed, onIn: onPressIn, onOut: onPressOut } =
140
+ useInteractionState();
135
141
 
136
142
  useEffect(() => {
137
143
  Animated.spring(rotateAnim, {
@@ -154,7 +160,7 @@ const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
154
160
 
155
161
  return (
156
162
  <Pressable
157
- style={({ pressed }) => [
163
+ style={[
158
164
  {
159
165
  flexDirection: 'row',
160
166
  alignItems: 'center',
@@ -166,6 +172,8 @@ const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
166
172
  style,
167
173
  ]}
168
174
  onPress={handlePress}
175
+ onPressIn={disabled ? undefined : onPressIn}
176
+ onPressOut={disabled ? undefined : onPressOut}
169
177
  disabled={disabled}
170
178
  accessibilityRole="button"
171
179
  accessibilityState={{ expanded: isExpanded, disabled }}
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  import { useTheme } from '../theme/use-theme';
15
15
  import { usePressAnimation } from '../hooks/usePressAnimation';
16
+ import { useInteractionState } from '../hooks/useInteractionState';
16
17
  import type { ButtonProps } from './types';
17
18
 
18
19
  export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
@@ -65,9 +66,34 @@ const ButtonComponent: React.FC<ButtonProps> = ({
65
66
  const theme = useTheme();
66
67
  const hasScaleFeedback = SCALE_VARIANTS.has(variant);
67
68
  const isInteractionBlocked = disabled || loading;
68
- const { scaleAnim, onPressIn, onPressOut } = usePressAnimation(
69
- hasScaleFeedback && !isInteractionBlocked ? PRESS_SCALE : undefined,
70
- );
69
+ const { scaleAnim, onPressIn: onScalePressIn, onPressOut: onScalePressOut } =
70
+ usePressAnimation(
71
+ hasScaleFeedback && !isInteractionBlocked ? PRESS_SCALE : undefined,
72
+ );
73
+ // Non-scale variants (icon/ghost/text) convey press feedback via an opacity
74
+ // dip. We drive it through component state + onPressIn/onPressOut rather than
75
+ // Pressable's function-form `style` because NativeWind v4's css-interop
76
+ // rewrites the `style` prop of every JSX element and swallows the function
77
+ // form, which would drop ALL base styles (background, radius, padding,
78
+ // minHeight). A static style array is merged correctly by css-interop.
79
+ const { state: pressed, onIn: onPressedIn, onOut: onPressedOut } =
80
+ useInteractionState();
81
+
82
+ const handlePressIn = useMemo(() => {
83
+ if (isInteractionBlocked) return undefined;
84
+ return () => {
85
+ onScalePressIn();
86
+ onPressedIn();
87
+ };
88
+ }, [isInteractionBlocked, onScalePressIn, onPressedIn]);
89
+
90
+ const handlePressOut = useMemo(() => {
91
+ if (isInteractionBlocked) return undefined;
92
+ return () => {
93
+ onScalePressOut();
94
+ onPressedOut();
95
+ };
96
+ }, [isInteractionBlocked, onScalePressOut, onPressedOut]);
71
97
 
72
98
  const baseStyles = useMemo((): ViewStyle => {
73
99
  const sizeConfig = SIZE_CONFIG[size];
@@ -163,15 +189,15 @@ const ButtonComponent: React.FC<ButtonProps> = ({
163
189
  <Animated.View style={hasScaleFeedback ? { transform: [{ scale: scaleAnim }] } : undefined}>
164
190
  <Pressable
165
191
  {...(resolvedClassName ? { className: resolvedClassName } as Record<string, string> : {})}
166
- style={({ pressed }) => [
192
+ style={[
167
193
  baseStyles,
168
194
  disabled && !loading && { opacity: 0.5 },
169
195
  pressed && !hasScaleFeedback && !isInteractionBlocked && { opacity: resolvedActiveOpacity },
170
196
  style,
171
197
  ]}
172
198
  onPress={isInteractionBlocked ? undefined : onPress}
173
- onPressIn={isInteractionBlocked ? undefined : onPressIn}
174
- onPressOut={isInteractionBlocked ? undefined : onPressOut}
199
+ onPressIn={handlePressIn}
200
+ onPressOut={handlePressOut}
175
201
  disabled={isInteractionBlocked}
176
202
  hitSlop={hitSlop ?? defaultHitSlop}
177
203
  accessibilityLabel={accessibilityLabel}
package/src/card/Card.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { memo, useMemo } from 'react';
2
2
  import { View, Text, Pressable, Platform, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { useTheme } from '../theme/use-theme';
5
+ import { useInteractionState } from '../hooks/useInteractionState';
5
6
  import { borderRadius, space } from '../styles/tokens';
6
7
  import type {
7
8
  CardProps,
@@ -22,6 +23,11 @@ const CardRootComponent: React.FC<CardProps> = ({
22
23
  testID,
23
24
  }) => {
24
25
  const theme = useTheme();
26
+ // Drive the press-opacity via state instead of Pressable's function-form
27
+ // `style`, which NativeWind v4's css-interop swallows (dropping the base
28
+ // container style: background, radius, border, shadow).
29
+ const { state: pressed, onIn: onPressIn, onOut: onPressOut } =
30
+ useInteractionState();
25
31
 
26
32
  const containerStyle = useMemo((): ViewStyle => {
27
33
  const base: ViewStyle = {
@@ -58,13 +64,15 @@ const CardRootComponent: React.FC<CardProps> = ({
58
64
  if (onPress) {
59
65
  return (
60
66
  <Pressable
61
- style={({ pressed }) => [
67
+ style={[
62
68
  containerStyle,
63
- pressed && { opacity: 0.85 },
69
+ pressed && !disabled && { opacity: 0.85 },
64
70
  disabled && { opacity: 0.5 },
65
71
  style,
66
72
  ]}
67
73
  onPress={onPress}
74
+ onPressIn={disabled ? undefined : onPressIn}
75
+ onPressOut={disabled ? undefined : onPressOut}
68
76
  disabled={disabled}
69
77
  accessibilityLabel={accessibilityLabel}
70
78
  accessibilityRole="button"
@@ -0,0 +1,125 @@
1
+ import React, { useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { Button } from '../button';
6
+ import { CenteredDialog } from './CenteredDialog';
7
+
8
+ const meta: Meta<typeof CenteredDialog> = {
9
+ title: 'Components/CenteredDialog',
10
+ component: CenteredDialog,
11
+ };
12
+
13
+ export default meta;
14
+
15
+ type Story = StoryObj<typeof CenteredDialog>;
16
+
17
+ function CompactDemo() {
18
+ const [visible, setVisible] = useState(false);
19
+ return (
20
+ <>
21
+ <Button onPress={() => setVisible(true)}>Open compact dialog</Button>
22
+ <CenteredDialog
23
+ visible={visible}
24
+ onClose={() => setVisible(false)}
25
+ title="Delete project?"
26
+ footer={
27
+ <View style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 8 }}>
28
+ <Button variant="ghost" size="small" onPress={() => setVisible(false)}>
29
+ Cancel
30
+ </Button>
31
+ <Button variant="primary" size="small" onPress={() => setVisible(false)}>
32
+ Delete
33
+ </Button>
34
+ </View>
35
+ }
36
+ >
37
+ <Text>This action cannot be undone. The project and all its data will be permanently removed.</Text>
38
+ </CenteredDialog>
39
+ </>
40
+ );
41
+ }
42
+
43
+ function CozyDemo() {
44
+ const [visible, setVisible] = useState(false);
45
+ return (
46
+ <>
47
+ <Button onPress={() => setVisible(true)}>Open cozy dialog</Button>
48
+ <CenteredDialog
49
+ visible={visible}
50
+ onClose={() => setVisible(false)}
51
+ title="Roomier layout"
52
+ compact={false}
53
+ >
54
+ <Text>compact={'{false}'} relaxes the padding and header/footer gaps for content-heavy dialogs.</Text>
55
+ </CenteredDialog>
56
+ </>
57
+ );
58
+ }
59
+
60
+ function ChromelessDemo() {
61
+ const [visible, setVisible] = useState(false);
62
+ return (
63
+ <>
64
+ <Button onPress={() => setVisible(true)}>Open chromeless dialog</Button>
65
+ <CenteredDialog visible={visible} onClose={() => setVisible(false)}>
66
+ <View style={{ gap: 12 }}>
67
+ <Text>No title, no close button — the card owns every pixel.</Text>
68
+ <Button variant="secondary" size="small" onPress={() => setVisible(false)}>
69
+ Done
70
+ </Button>
71
+ </View>
72
+ </CenteredDialog>
73
+ </>
74
+ );
75
+ }
76
+
77
+ function NonDismissibleDemo() {
78
+ const [visible, setVisible] = useState(false);
79
+ return (
80
+ <>
81
+ <Button onPress={() => setVisible(true)}>Open blocking dialog</Button>
82
+ <CenteredDialog
83
+ visible={visible}
84
+ onClose={() => setVisible(false)}
85
+ title="Action required"
86
+ dismissible={false}
87
+ showClose={false}
88
+ footer={
89
+ <Button size="small" onPress={() => setVisible(false)}>
90
+ Acknowledge
91
+ </Button>
92
+ }
93
+ >
94
+ <Text>Backdrop tap and Escape are disabled — the user must choose an action.</Text>
95
+ </CenteredDialog>
96
+ </>
97
+ );
98
+ }
99
+
100
+ export const Compact: Story = {
101
+ render: () => <CompactDemo />,
102
+ };
103
+
104
+ export const Cozy: Story = {
105
+ render: () => <CozyDemo />,
106
+ };
107
+
108
+ export const Chromeless: Story = {
109
+ render: () => <ChromelessDemo />,
110
+ };
111
+
112
+ export const NonDismissible: Story = {
113
+ render: () => <NonDismissibleDemo />,
114
+ };
115
+
116
+ export const Gallery: Story = {
117
+ render: () => (
118
+ <View style={{ gap: 12, alignItems: 'flex-start' }}>
119
+ <CompactDemo />
120
+ <CozyDemo />
121
+ <ChromelessDemo />
122
+ <NonDismissibleDemo />
123
+ </View>
124
+ ),
125
+ };
@@ -0,0 +1,295 @@
1
+ import React, { useMemo } from 'react';
2
+ import {
3
+ Modal,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ View,
8
+ } from 'react-native';
9
+
10
+ import { useInteractionState } from '../hooks/useInteractionState';
11
+ import { TimesLarge_Stroke2_Corner0_Rounded as CloseIcon } from '../icons/Times';
12
+ import { useTheme } from '../theme/use-theme';
13
+ import { Text } from '../typography';
14
+ import type { CenteredDialogProps } from './centered-dialog-types';
15
+ import {
16
+ CARD_RADIUS,
17
+ DEFAULT_MAX_WIDTH,
18
+ MAX_HEIGHT_FRACTION,
19
+ resolveSpacing,
20
+ VIEWPORT_GUTTER,
21
+ } from './centered-dialog-tokens';
22
+
23
+ const CLOSE_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
24
+
25
+ /** Stable testID for the dimmed backdrop. Overridden when a `testID` is set. */
26
+ export const CENTERED_DIALOG_BACKDROP_TESTID = 'bloom-centered-dialog-backdrop';
27
+
28
+ /**
29
+ * Native variant of `<CenteredDialog>`.
30
+ *
31
+ * A controlled, centered modal: a transparent RN `Modal` (fade animation,
32
+ * own native window) hosting a dimmed full-screen backdrop and a centered,
33
+ * rounded card styled entirely from Bloom theme tokens.
34
+ *
35
+ * The backdrop is a sibling `Pressable` rendered BEHIND the card (absolute
36
+ * filled), never a parent of it — so a tap on the card never bubbles to the
37
+ * backdrop's close handler and the card never ends up nested inside the
38
+ * backdrop's pressable on web.
39
+ *
40
+ * Dismissal: backdrop tap (when `dismissible`), the header close button, and
41
+ * the Android hardware back button (`onRequestClose`) all call `onClose`.
42
+ */
43
+ export function CenteredDialog({
44
+ visible,
45
+ onClose,
46
+ title,
47
+ dismissible = true,
48
+ maxWidth = DEFAULT_MAX_WIDTH,
49
+ compact = true,
50
+ showClose,
51
+ children,
52
+ footer,
53
+ accessibilityLabel,
54
+ closeAccessibilityLabel = 'Close dialog',
55
+ backdropAccessibilityLabel = 'Dismiss dialog',
56
+ cardStyle,
57
+ contentStyle,
58
+ testID,
59
+ }: CenteredDialogProps) {
60
+ const theme = useTheme();
61
+ const backdropPress = useInteractionState();
62
+
63
+ const spacing = useMemo(() => resolveSpacing(compact), [compact]);
64
+ const resolvedShowClose = showClose ?? Boolean(title);
65
+ const hasHeader = Boolean(title) || resolvedShowClose;
66
+
67
+ const handleBackdropPress = dismissible ? onClose : undefined;
68
+ // Android's hardware back must always be answerable; when the dialog is
69
+ // non-dismissible we still need a handler (RN requires one for transparent
70
+ // modals) — make it a no-op so back does nothing rather than crash.
71
+ const handleRequestClose = dismissible ? onClose : noop;
72
+
73
+ return (
74
+ <Modal
75
+ visible={visible}
76
+ transparent
77
+ animationType="fade"
78
+ statusBarTranslucent
79
+ onRequestClose={handleRequestClose}
80
+ >
81
+ <View style={[styles.root, { padding: VIEWPORT_GUTTER }]}>
82
+ <Pressable
83
+ testID={testID ? `${testID}-backdrop` : CENTERED_DIALOG_BACKDROP_TESTID}
84
+ style={[
85
+ styles.backdrop,
86
+ { backgroundColor: theme.colors.overlay },
87
+ backdropPress.state && styles.backdropPressed,
88
+ ]}
89
+ onPress={handleBackdropPress}
90
+ onPressIn={backdropPress.onIn}
91
+ onPressOut={backdropPress.onOut}
92
+ disabled={!dismissible}
93
+ accessibilityRole="button"
94
+ accessibilityLabel={backdropAccessibilityLabel}
95
+ />
96
+
97
+ {/* RN's `Modal` already isolates its contents in a separate
98
+ accessibility window, so an explicit `accessibilityViewIsModal`
99
+ on the card is redundant here (and would mark the sibling
100
+ backdrop inaccessible). The web fork uses `aria-modal` instead. */}
101
+ <View
102
+ testID={testID}
103
+ accessibilityLabel={accessibilityLabel ?? title}
104
+ style={[
105
+ styles.card,
106
+ {
107
+ maxWidth,
108
+ maxHeight: `${MAX_HEIGHT_FRACTION * 100}%`,
109
+ backgroundColor: theme.colors.card,
110
+ borderColor: theme.colors.border,
111
+ shadowColor: theme.colors.shadow,
112
+ },
113
+ cardStyle,
114
+ ]}
115
+ >
116
+ {hasHeader ? (
117
+ <DialogHeader
118
+ title={title}
119
+ showClose={resolvedShowClose}
120
+ padding={spacing.pad}
121
+ gap={spacing.headerGap}
122
+ closeAccessibilityLabel={closeAccessibilityLabel}
123
+ onClose={onClose}
124
+ />
125
+ ) : null}
126
+
127
+ <ScrollView
128
+ style={styles.bodyScroll}
129
+ contentContainerStyle={[
130
+ {
131
+ padding: spacing.pad,
132
+ paddingTop: hasHeader ? spacing.headerGap : spacing.pad,
133
+ gap: spacing.contentGap,
134
+ },
135
+ contentStyle,
136
+ ]}
137
+ showsVerticalScrollIndicator={false}
138
+ keyboardShouldPersistTaps="handled"
139
+ >
140
+ {children}
141
+ </ScrollView>
142
+
143
+ {footer ? (
144
+ <View
145
+ style={[
146
+ styles.footer,
147
+ {
148
+ padding: spacing.pad,
149
+ paddingTop: spacing.footerGap,
150
+ borderTopColor: theme.colors.borderLight,
151
+ },
152
+ ]}
153
+ >
154
+ {footer}
155
+ </View>
156
+ ) : null}
157
+ </View>
158
+ </View>
159
+ </Modal>
160
+ );
161
+ }
162
+
163
+ function DialogHeader({
164
+ title,
165
+ showClose,
166
+ padding,
167
+ gap,
168
+ closeAccessibilityLabel,
169
+ onClose,
170
+ }: {
171
+ title?: string;
172
+ showClose: boolean;
173
+ padding: number;
174
+ gap: number;
175
+ closeAccessibilityLabel: string;
176
+ onClose: () => void;
177
+ }) {
178
+ const theme = useTheme();
179
+
180
+ return (
181
+ <View
182
+ style={[
183
+ styles.header,
184
+ { paddingHorizontal: padding, paddingTop: padding, gap },
185
+ ]}
186
+ >
187
+ {title ? (
188
+ <Text
189
+ accessibilityRole="header"
190
+ numberOfLines={2}
191
+ style={[styles.title, { color: theme.colors.text }]}
192
+ >
193
+ {title}
194
+ </Text>
195
+ ) : (
196
+ <View style={styles.titleSpacer} />
197
+ )}
198
+ {showClose ? (
199
+ <CloseButton
200
+ accessibilityLabel={closeAccessibilityLabel}
201
+ onPress={onClose}
202
+ />
203
+ ) : null}
204
+ </View>
205
+ );
206
+ }
207
+
208
+ function CloseButton({
209
+ accessibilityLabel,
210
+ onPress,
211
+ }: {
212
+ accessibilityLabel: string;
213
+ onPress: () => void;
214
+ }) {
215
+ const theme = useTheme();
216
+ const press = useInteractionState();
217
+
218
+ return (
219
+ <Pressable
220
+ onPress={onPress}
221
+ onPressIn={press.onIn}
222
+ onPressOut={press.onOut}
223
+ hitSlop={CLOSE_HIT_SLOP}
224
+ accessibilityRole="button"
225
+ accessibilityLabel={accessibilityLabel}
226
+ style={[
227
+ styles.closeButton,
228
+ { backgroundColor: theme.colors.contrast50 },
229
+ press.state && styles.closeButtonPressed,
230
+ ]}
231
+ >
232
+ <CloseIcon size="sm" fill={theme.colors.textSecondary} />
233
+ </Pressable>
234
+ );
235
+ }
236
+
237
+ function noop(): void {
238
+ /* non-dismissible: hardware back is intentionally inert */
239
+ }
240
+
241
+ const styles = StyleSheet.create({
242
+ root: {
243
+ flex: 1,
244
+ alignItems: 'center',
245
+ justifyContent: 'center',
246
+ },
247
+ backdrop: {
248
+ ...StyleSheet.absoluteFillObject,
249
+ },
250
+ // A subtle deepening on press confirms the tap-to-dismiss affordance.
251
+ backdropPressed: {
252
+ opacity: 0.92,
253
+ },
254
+ card: {
255
+ width: '100%',
256
+ borderRadius: CARD_RADIUS,
257
+ borderWidth: StyleSheet.hairlineWidth,
258
+ overflow: 'hidden',
259
+ shadowOpacity: 0.18,
260
+ shadowRadius: 24,
261
+ shadowOffset: { width: 0, height: 8 },
262
+ elevation: 12,
263
+ },
264
+ header: {
265
+ flexDirection: 'row',
266
+ alignItems: 'flex-start',
267
+ justifyContent: 'space-between',
268
+ },
269
+ title: {
270
+ flex: 1,
271
+ fontSize: 17,
272
+ fontWeight: '700',
273
+ lineHeight: 22,
274
+ },
275
+ titleSpacer: {
276
+ flex: 1,
277
+ },
278
+ closeButton: {
279
+ width: 28,
280
+ height: 28,
281
+ borderRadius: 14,
282
+ alignItems: 'center',
283
+ justifyContent: 'center',
284
+ },
285
+ closeButtonPressed: {
286
+ opacity: 0.7,
287
+ },
288
+ bodyScroll: {
289
+ flexGrow: 0,
290
+ flexShrink: 1,
291
+ },
292
+ footer: {
293
+ borderTopWidth: StyleSheet.hairlineWidth,
294
+ },
295
+ });