@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.
- package/lib/commonjs/accordion/Accordion.js +12 -3
- package/lib/commonjs/accordion/Accordion.js.map +1 -1
- package/lib/commonjs/button/Button.js +31 -7
- package/lib/commonjs/button/Button.js.map +1 -1
- package/lib/commonjs/card/Card.js +12 -3
- package/lib/commonjs/card/Card.js.map +1 -1
- package/lib/commonjs/dialog/CenteredDialog.js +242 -0
- package/lib/commonjs/dialog/CenteredDialog.js.map +1 -0
- package/lib/commonjs/dialog/CenteredDialog.web.js +326 -0
- package/lib/commonjs/dialog/CenteredDialog.web.js.map +1 -0
- package/lib/commonjs/dialog/centered-dialog-tokens.js +62 -0
- package/lib/commonjs/dialog/centered-dialog-tokens.js.map +1 -0
- package/lib/commonjs/dialog/centered-dialog-types.js +6 -0
- package/lib/commonjs/dialog/centered-dialog-types.js.map +1 -0
- package/lib/commonjs/dialog/index.js +13 -0
- package/lib/commonjs/dialog/index.js.map +1 -1
- package/lib/commonjs/dialog/index.web.js +19 -0
- package/lib/commonjs/dialog/index.web.js.map +1 -1
- package/lib/commonjs/index.js +14 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +14 -0
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/segmented-control/index.js +12 -3
- package/lib/commonjs/segmented-control/index.js.map +1 -1
- package/lib/commonjs/settings-list/SettingsList.js +11 -3
- package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
- package/lib/commonjs/toast/Toast.js +13 -6
- package/lib/commonjs/toast/Toast.js.map +1 -1
- package/lib/module/accordion/Accordion.js +12 -3
- package/lib/module/accordion/Accordion.js.map +1 -1
- package/lib/module/button/Button.js +31 -7
- package/lib/module/button/Button.js.map +1 -1
- package/lib/module/card/Card.js +12 -3
- package/lib/module/card/Card.js.map +1 -1
- package/lib/module/dialog/CenteredDialog.js +236 -0
- package/lib/module/dialog/CenteredDialog.js.map +1 -0
- package/lib/module/dialog/CenteredDialog.web.js +320 -0
- package/lib/module/dialog/CenteredDialog.web.js.map +1 -0
- package/lib/module/dialog/centered-dialog-tokens.js +58 -0
- package/lib/module/dialog/centered-dialog-tokens.js.map +1 -0
- package/lib/module/dialog/centered-dialog-types.js +4 -0
- package/lib/module/dialog/centered-dialog-types.js.map +1 -0
- package/lib/module/dialog/index.js +1 -0
- package/lib/module/dialog/index.js.map +1 -1
- package/lib/module/dialog/index.web.js +1 -0
- package/lib/module/dialog/index.web.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/segmented-control/index.js +12 -3
- package/lib/module/segmented-control/index.js.map +1 -1
- package/lib/module/settings-list/SettingsList.js +11 -3
- package/lib/module/settings-list/SettingsList.js.map +1 -1
- package/lib/module/toast/Toast.js +13 -6
- package/lib/module/toast/Toast.js.map +1 -1
- package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
- package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
- package/lib/typescript/commonjs/card/Card.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts +20 -0
- package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts +29 -0
- package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/centered-dialog-tokens.d.ts +47 -0
- package/lib/typescript/commonjs/dialog/centered-dialog-tokens.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts +70 -0
- package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/index.d.ts +2 -0
- package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -2
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/segmented-control/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/settings-list/SettingsList.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/Toast.d.ts.map +1 -1
- package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
- package/lib/typescript/module/button/Button.d.ts.map +1 -1
- package/lib/typescript/module/card/Card.d.ts.map +1 -1
- package/lib/typescript/module/dialog/CenteredDialog.d.ts +20 -0
- package/lib/typescript/module/dialog/CenteredDialog.d.ts.map +1 -0
- package/lib/typescript/module/dialog/CenteredDialog.web.d.ts +29 -0
- package/lib/typescript/module/dialog/CenteredDialog.web.d.ts.map +1 -0
- package/lib/typescript/module/dialog/centered-dialog-tokens.d.ts +47 -0
- package/lib/typescript/module/dialog/centered-dialog-tokens.d.ts.map +1 -0
- package/lib/typescript/module/dialog/centered-dialog-types.d.ts +70 -0
- package/lib/typescript/module/dialog/centered-dialog-types.d.ts.map +1 -0
- package/lib/typescript/module/dialog/index.d.ts +2 -0
- package/lib/typescript/module/dialog/index.d.ts.map +1 -1
- package/lib/typescript/module/dialog/index.web.d.ts +2 -0
- package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -2
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/segmented-control/index.d.ts.map +1 -1
- package/lib/typescript/module/settings-list/SettingsList.d.ts.map +1 -1
- package/lib/typescript/module/toast/Toast.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/CenteredDialog.test.tsx +108 -0
- package/src/accordion/Accordion.tsx +9 -1
- package/src/button/Button.tsx +32 -6
- package/src/card/Card.tsx +10 -2
- package/src/dialog/CenteredDialog.stories.tsx +125 -0
- package/src/dialog/CenteredDialog.tsx +295 -0
- package/src/dialog/CenteredDialog.web.tsx +370 -0
- package/src/dialog/centered-dialog-tokens.ts +68 -0
- package/src/dialog/centered-dialog-types.ts +70 -0
- package/src/dialog/index.ts +2 -0
- package/src/dialog/index.web.ts +6 -0
- package/src/index.ts +3 -0
- package/src/index.web.ts +3 -0
- package/src/segmented-control/index.tsx +9 -1
- package/src/settings-list/SettingsList.tsx +8 -1
- 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={
|
|
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 }}
|
package/src/button/Button.tsx
CHANGED
|
@@ -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 } =
|
|
69
|
-
|
|
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={
|
|
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={
|
|
174
|
-
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={
|
|
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
|
+
});
|